Contributing to ttf-parser
August 6, 2026 · View on GitHub
ttf-parser is a #![no_std], #![forbid(unsafe_code)], zero-allocation parser for
TrueType, OpenType and AAT fonts. It sits underneath text rendering and it parses untrusted
input, so contributions are held to that standard.
Scope
The crate is in maintenance mode: bug fixes only.
In scope, and welcome:
- Incorrect parsing, wrong values, glyphs that fail to outline
- Panics, aborts and unbounded work on malformed input
- Fixes to existing table support
Out of scope, and will be closed:
- New table support and new public API
- Performance work
- Refactors that change the API surface
If you need broader coverage, fontations is actively developed and is what we recommend for new projects.
A bug report does not need to be phrased as a fix — if a font renders wrong, say so and we will work out whether the cause is in scope.
Repository layout
The repository root is not a Cargo workspace. It holds four independent crates:
| Path | What it is |
|---|---|
. | the ttf-parser library |
c-api/ | C bindings, producing libttfparser and a cbindgen-generated ttfparser.h |
benches/ | benchmarks against stb_truetype, FreeType and others |
testing-tools/ttf-fuzz/ | legacy afl fuzz targets, unmaintained (see below) |
testing-tools/font-view/ is a Qt/C++ visual debugging tool. It is not built by CI.
Because these are separate crates, commands have to be run from the right directory.
cargo test at the root does not touch c-api/.
MSRV and edition
MSRV is 1.88.0 and the crate is on edition 2024. The MSRV appears in three places
that must agree: rust-version in Cargo.toml, the CI matrix in
.github/workflows/main.yml, and the badge in README.md.
Building and testing
The library
The feature matrix matters more than usual: std and no-std-float are mutually exclusive
floating-point backends, one of which is mandatory, and variable-fonts roughly doubles
binary size. CI builds every one of these:
cargo build --no-default-features --features=no-std-float
cargo build --no-default-features --features=std
cargo build --no-default-features --features=alloc,no-std-float,variable-fonts,gvar-alloc
cargo build --no-default-features --features=variable-fonts,no-std-float
cargo build --all-features
A change that compiles under --all-features but not under
--no-default-features --features=no-std-float is the most common CI failure. If you touch
float arithmetic or anything behind a #[cfg(feature = ...)], build the no-std leg locally.
Tests, in both profiles
cargo test
cargo test --release
Run both. debug_assert! is compiled out in release, so the two profiles can disagree
about whether malformed input aborts or is rejected cleanly. A test that passed in debug and
failed in release went unnoticed on main for months because CI ran only debug.
The C API
cd c-api
cargo build --no-default-features
cargo build --no-default-features --features=variable-fonts
cargo test
cargo test here is not optional: the root is not a workspace, so a root cargo test never
reaches this crate. Its test module went unrun for its entire existence.
The C smoke test is compiled with AddressSanitizer and -Werror against the freshly built
dynamic library:
cargo build
gcc test.c -o test -L./target/debug/ -lttfparser -Werror -fsanitize=address
env LD_LIBRARY_PATH=./target/debug/ ./test
On macOS, use clang and DYLD_LIBRARY_PATH.
c-api/ttfparser.h is cbindgen-generated and then hand-edited. If you change an exported
signature, update the header to match; it is not regenerated automatically.
Benchmarks
cd benches
cargo bench dummy # `cargo build` does not actually build them
Linting and formatting
Both are enforced in CI and both must pass:
poly lint --no-workspace .
poly fmt --check .
poly fmt --fix . # to apply
--no-workspace matters: without it, poly lint shells out to cargo clippy and friends.
This crate has never carried a clippy configuration, so that produces an unbounded set of
findings unrelated to your change.
Configuration lives in poly.toml. Two exclusions are deliberate and should not be removed
without discussion:
tests/tables/**— hand-built binary fixtures where one source line is one logical record and the trailing comment names it. A formatter puts each literal on its own line, which detaches the comment from what it describes. This is why those modules are declared#[rustfmt::skip] mod x;intests/tables/main.rs.c-api/ttfparser.h— generated then hand-edited; formatting fights regeneration.
Comments are part of the source
This is the one rule worth stating explicitly.
This crate's comments encode OpenType, TrueType and AAT specification detail that cannot be
re-derived from the code: why a loca table has numGlyphs + 1 offsets, which binary
search invariant a function relies on, why deltas_are_zero overrides deltas_are_words,
the operand layout of a CFF charstring. Deleting them destroys knowledge that took someone a
spec reading to acquire.
Do not run comment-stripping tools over this repository. poly's uncomment engine is
pinned off in poly.toml, with per-file-ignores on the comment and doc-comment
diagnostic codes as a backstop that survives a later config change. Note that a standalone
uncomment binary exists outside poly and no poly.toml can govern it — that one is
guarded by review only.
Mark a comment that carries non-inferrable rationale with ~keep.
Writing tests
Tests live in tests/tables/, one module per table, registered in tests/tables/main.rs.
Build fixtures inline with the convert(&[Unit...]) helper rather than committing binary
font files; tests/tables/cff1.rs shows the pattern for something as involved as a full
CFF font.
Two expectations:
- Assert exact values, not truthiness.
assert_eq!(result, 42), notassert!(result). - Verify the test fails without the fix. Revert your change, watch the test go red, then
restore it. A test that passes against broken code is not testing anything. For security
fixes, check both profiles — an argument-stack underflow shows as a
debug_assertin debug and as an out-of-bounds index in release, and only the release message reveals the real failure.
Security-relevant changes
Most open work on this crate is hardening against malformed input. If you are adding a limit:
- Give it a named constant with a comment justifying the value, ideally citing a measured corpus or a comparable limit in harfbuzz or fontations. "Chosen arbitrarily" invites someone to raise it later without evidence.
- Bound the right thing. A depth cap does not bound total work: a graph with fan-out
band depthdperformsb^dvisits while never exceeding depthd. Several bugs here were exactly this mistake. - Do not leak state between calls. A budget must be a fresh local per public entry point. A counter that persists makes the API start failing spuriously after enough calls.
- Charge the budget before any early return, or an attacker gets the cheap paths free.
Commit messages
Commits follow Conventional Commits:
type(scope): subject
Body explaining why, if the subject is not self-evident.
- Types:
fix,feat,docs,test,refactor,perf,build,ci,chore. Given the crate is in maintenance mode, nearly everything isfix,docs,testorchore. - Scope is the table or module the change belongs to, matching how the changelog groups
entries:
fix(cmap):,fix(CFF2):,fix(gvar):. Omit it for changes that span the crate. - Subject is imperative mood, no trailing period, first line under 72 characters.
- The body explains why, not what — the diff already says what. Reference an issue by number when one exists.
- A breaking change gets a
!after the type (feat(cff)!:) and a note in the changelog under### Changed.
History before 0.25.1 predates this convention and is not being rewritten, so git log mixes
both styles. New commits use the convention.
Pull requests
- Add a
## [Unreleased]entry toCHANGELOG.md. Contributed changes are credited in the formThanks to [name](https://github.com/name). - Call out breaking changes explicitly under
### Changed, including behavioural ones that produce no compile error. - One logical change per PR. Unrelated fixes riding along make review slower and bisection worse.
Fuzzing
testing-tools/ttf-fuzz/ is not maintained and does not compile — it has called
Face::parse expecting an Option since that function started returning a Result in
0.8.0. Do not use it as a starting point.
The crate is fuzzed by OSS-Fuzz, whose targets are currently vendored in the google/oss-fuzz
repository rather than here. See the open issue about OSS-Fuzz project ownership before
investing effort in fuzzing infrastructure.