Skip to content

Add a C API - #460

Merged
dfrg merged 44 commits into
mainfrom
c-api
Sep 10, 2026
Merged

dfrg merged 44 commits into
mainfrom
c-api

Conversation

@dfrg

@dfrg dfrg commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Adds the harfrust-capi crate, mirroring the shaping half of HarfBuzz's API
with an hr_ prefix in place of hb_. Every type, function, enumerator and
constant is named and numbered to match its HarfBuzz counterpart, so C code
can usually be ported by renaming hb_ to hr_, and the prefix lets the two
libraries coexist in one process.

Covered are blobs, faces, fonts, font callbacks, buffers, shape plans and
hr_shape, along with the tags, directions, scripts, languages, features and
variations they need. Faces can be built over a blob or from a table callback.
Objects are reference counted, carry user data and can be made immutable, and
the _create functions return immortal empty objects rather than NULL, all as
in HarfBuzz.

hr_glyph_info_t and hr_glyph_position_t share HarfRust's layout exactly,
asserted at compile time, so the buffer accessors hand back pointers into its
storage rather than copying.

Since HarfRust builds a shape plan per call and has no cache of its own, one
is kept per face, so repeated calls over the same face, segment properties
and features reuse a plan the way hb_shape does internally. Nothing needs
invalidating: everything else a plan depends on is fixed for the life of a
face, and the feature variation indices that are not are part of the key.
hr_shape_plan_create_cached draws from that same cache, so a plan the
caller holds and one hr_shape uses are the same object.

hr_shape_plan_execute aborts when handed a plan that does not apply, as
HarfBuzz's assertions do: the plan must have been built over the same face,
for the same variation settings, and for the properties the buffer carries.
HarfBuzz compiles its assertions out with NDEBUG; these are always on, since
HarfRust asserts unconditionally on a mismatched plan anyway and a clear
message beats one from deep inside the shaper.

Flag types, hr_script_t and hr_direction_t are integer typedefs rather
than enumerations. C combines flags with a bitwise or and fills a
hr_segment_properties_t in itself, so neither can be held in a Rust
enumeration without inviting undefined behaviour on values it does not list.
Their constants keep HarfBuzz's values and still work as switch labels.

The header is generated with cbindgen and committed at include/hr.h. It
compiles clean as C99, C11 and C17 and as C++11, C++17 and C++20, under gcc,
clang and MSVC.

@behdad

behdad commented Sep 1, 2026

Copy link
Copy Markdown
Member

This is great! Truly agentic possibility...

Idea: Maybe add a hr-hb.h that macro-defines all hb_ symbols to hr_ symbols, such that clients can just include that and compile business as usual.

@behdad

behdad commented Sep 1, 2026

Copy link
Copy Markdown
Member

Running past the
length, operation or nesting limits stays a reported failure, since
pathological input can provoke it and a caller can recover; that is the one
failure HarfBuzz also lets hb_shape_full return.

I think this is not the case: hb_shape_full basically fails if no shaper could match the font's conditions. I think limits-overflow is only returned with hb_buffer_allocation_successful.

@dfrg

dfrg commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Removing that error case from the buffer branch which will follow here.

Comment thread harfrust_capi/include/hr-hb.h
Base automatically changed from buffer-unify to main September 2, 2026 00:51
Comment thread harfrust-capi/src/shape.rs Outdated
@dfrg

dfrg commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed some soundness issues, improved performance and made it pass miri.

@behdad
behdad marked this pull request as ready for review September 5, 2026 02:36
@behdad

This comment was marked as outdated.

Comment thread harfrust/src/hb/face.rs Outdated
Comment thread harfrust-capi/src/font.rs Outdated
@behdad

behdad commented Sep 5, 2026

Copy link
Copy Markdown
Member

@dfrg PTAL

@behdad
behdad force-pushed the c-api branch 2 times, most recently from 5ba11ba to 4c90e8e Compare September 6, 2026 04:38
dfrg and others added 13 commits September 6, 2026 14:56
Adds the harfrust-capi crate, mirroring the shaping half of HarfBuzz's API
with an `hr_` prefix in place of `hb_`. Every type, function, enumerator and
constant is named and numbered to match its HarfBuzz counterpart, so C code
can usually be ported by renaming `hb_` to `hr_`, and the prefix lets the two
libraries coexist in one process.

Covered are blobs, faces, fonts, font callbacks, buffers, shape plans and
`hr_shape`, along with the tags, directions, scripts, languages, features and
variations they need. Faces can be built over a blob or from a table callback.
Objects are reference counted, carry user data and can be made immutable, and
the `_create` functions return immortal empty objects rather than NULL, all as
in HarfBuzz.

`hr_glyph_info_t` and `hr_glyph_position_t` share HarfRust's layout exactly,
asserted at compile time, so the buffer accessors hand back pointers into its
storage rather than copying.

Since HarfRust builds a shape plan per call and has no cache of its own, one
is kept per face, so repeated calls over the same face, segment properties
and features reuse a plan the way `hb_shape` does internally. Nothing needs
invalidating: everything else a plan depends on is fixed for the life of a
face, and the feature variation indices that are not are part of the key.
`hr_shape_plan_create_cached` draws from that same cache, so a plan the
caller holds and one `hr_shape` uses are the same object.

`hr_shape_plan_execute` aborts when handed a plan that does not apply, as
HarfBuzz's assertions do: the plan must have been built over the same face,
for the same variation settings, and for the properties the buffer carries.
HarfBuzz compiles its assertions out with NDEBUG; these are always on, since
HarfRust asserts unconditionally on a mismatched plan anyway and a clear
message beats one from deep inside the shaper.

Flag types, `hr_script_t` and `hr_direction_t` are integer typedefs rather
than enumerations. C combines flags with a bitwise or and fills a
`hr_segment_properties_t` in itself, so neither can be held in a Rust
enumeration without inviting undefined behaviour on values it does not list.
Their constants keep HarfBuzz's values and still work as switch labels.

The header is generated with cbindgen and committed at include/hr.h. It
compiles clean as C99, C11 and C17 and as C++11, C++17 and C++20, under gcc,
clang and MSVC.
`Buffer::shape` now says why it could not shape, so `hr_shape_full` reports
that rather than only whether the call panicked: a buffer already holding
glyphs, a font with nothing to shape with, or shaping that ran past its
limits all come back as false.

`hr_shape_plan_execute` is unaffected. It checks the plan against the font
and buffer itself, and aborts before reaching this.
Two changes toward being a drop-in replacement.

`hr_shape` and `hr_shape_full` now abort when the API is misused, as
HarfBuzz's assertions do, rather than reporting it: a buffer that already
holds glyphs, or a font with nothing to shape with. `hr_shape` returns
nothing, so it could not otherwise report these at all. Running past the
length, operation or nesting limits stays a reported failure, since
pathological input can provoke it and a caller can recover; that is the one
failure HarfBuzz also lets `hb_shape_full` return.

`include/hr-hb.h` maps every HarfBuzz name onto its HarfRust counterpart, so
existing shaping code builds against this library by including it in place of
<hb.h>. It is generated from hr.h by scripts/gen-hb-compat-header.py, and two
tests check that every name is covered and that none is mapped that hr.h no
longer declares. `examples/hb-compat.c` is `shape.c` written entirely in
HarfBuzz's names, and mentions HarfRust nowhere.

Writing that example turned up macros hr.h was missing, which are now there in
their own right rather than only for the shim: `HR_TAG` and `HR_UNTAG`, the
`HR_DIRECTION_IS_*` predicates and `HR_DIRECTION_REVERSE`, and the version
macros. `HR_FEATURE_GLOBAL_END` was declared as `c_uint::MAX`, which cbindgen
cannot evaluate and had silently dropped; it is a literal now, with a test
that the committed header carries the crate's version.
`ShapeError` no longer carries the case where shaping ran out of room, so
`hr_shape_full` reads it from `Buffer::allocation_successful` instead. Its
contract is unchanged: false when the shaper gave up on pathological input,
and an abort when the API is misused.
An audit of the unsafe code, backed by Miri, turned up five ways a caller
following the documented rules could still reach freed memory.

A sub-font copied its parent's `font_data` pointer without owning it, so
replacing the parent's callbacks ran the destroy callback while the sub-font
was still using the data. Font data now lives in an `Arc`, and the last font
holding it releases it.

Shaping held a borrow of the font across the call. Callbacks are handed that
same font, so one that set its variations dropped the very `FontInstance`
being shaped through. Instances are now shared, and shaping takes a snapshot
of everything it needs before starting, so nothing borrows the font while
callbacks can run. The snapshot also owns a reference to the callbacks and to
the font data, and `hr_shape_plan_execute` takes a share of the plan, so a
callback replacing or destroying any of them cannot pull it out from under
the call.

Miri caught the fifth: `shaper_list_allows_ot` built a reference to the first
element of the shaper list and walked past it, which reads the right memory
but is out of bounds of that reference. It walks the caller's pointer now.
The empty singletons cached an address rather than a pointer, losing the
provenance needed to dereference it; they hold the pointer itself now.

Reads outnumber writes at every lock here, so `user_data`, the interned
languages and the per-face plan cache move to `RwLock`. The plan cache is the
one that matters: a hit only reads, so shaping the same face from several
threads no longer serialises. The default language was taking a lock on every
call to read something computed once, and is a `OnceLock` now.

Tests cover the font data lifetime, and shaping through a shared face and a
shared font from several threads. The whole suite runs clean under Miri, on
both Stacked and Tree Borrows, and the threading tests run clean under its
data race detector across a spread of interleavings.
Profiling a two-glyph CJK shape with caller-supplied font funcs, which is
what a browser integration does, put roughly 44% of the call in this crate
rather than in the shaper. Four things were being paid for on every call:

- The plan cache took a read lock and returned a counted reference. It now
  walks a singly linked list whose links are written once, the way HarfBuzz
  walks the atomic list on its own faces. No node is ever unlinked, so a
  reader needs no lock, and the nodes outlive any shaping call, so a hit
  hands back a borrow rather than touching a reference count. Four atomics
  become none. `hr_shape_plan_create_cached` still wants an owned plan, so
  the nodes hold counted ones and only that path clones.

- The plan cache built an owned key, cloning the language and copying the
  features, before searching for one. It now compares against the caller's
  values and only builds a key when it has a plan to store under it.

- `shape_with_plan` cloned the font instance out of its own snapshot, for a
  callback adapter that never reads it. The clone was there because the
  snapshot has a `Drop` implementation and so cannot be destructured; the
  half that needs dropping now lives in its own type, and the instance is
  moved.

- Text arrived through the lossy UTF-8 decoder, which walks the bytes to
  find ill-formed sequences and then again to yield characters. Well-formed
  text now takes `str::from_utf8`, with the lossy path kept for the rest.

Shaping two Chinese characters with caller-supplied funcs is 17% faster,
Japanese 9%, Korean 8%, and a Latin word 2%. Against HarfBuzz built with
clang, the short CJK cases go from 1.23x, 1.12x and 1.06x to 0.99x, 1.03x
and 1.00x. Output is unchanged: the shaped glyphs and positions checksum
identically across every case.
The cache was capped at 32 plans. That made sense when it was a vector that
could drop its oldest entry, but the list that replaced it never unlinks a
node, because handing out borrows depends on nodes outliving the call. So
the cap no longer evicted anything: a face that saw a thirty-third key
simply stopped caching, and every call after that rebuilt its plan.

Going cold permanently is a worse failure than growing, and it is not the
behaviour callers are used to. HarfBuzz keeps one list per face, prepends to
it, and frees it only when the face is destroyed; there is no cap and no
eviction anywhere in it. Growth is bounded in practice by the distinct
combinations of segment properties, user features and feature variation
indices a face actually sees.

Dropping the cap makes the fallback that built an uncached plan unreachable,
so the enum that carried it goes too, and a hit now returns the counted plan
directly.

The list is freed iteratively. Boxed nodes would otherwise drop recursively,
one stack frame per plan, which is fine for 32 and not for an unbounded
list; HarfBuzz frees its own list with the same explicit loop.
hr_shape_full returned false when the buffer had run past its length,
operation or nesting limits. HarfBuzz does not do that: hb_shape_full
reports failure only when it cannot find or instantiate a shaper -- an
invalid shape plan, a shaper_list naming none that it has, or shaper
data it could not build -- and _hb_ot_shape returns true unconditionally
whatever became of the buffer. A caller there learns about exhaustion
from hb_buffer_allocation_successful, which we already expose as
hr_buffer_allocation_successful.

False now means only that no shaper could be run, which also settles a
disagreement inside this crate: hr_shape_plan_execute shapes through
shape_with_plan, which already returned true whenever shaping ran.

Regenerated hr.h; hr-hb.h declares no new names and is unchanged.
hr_buffer_add_utf8 decoded the entire pre- and post-context even though a
buffer retains only five context codepoints.  Repeatedly shaping lines from
one large text therefore rescanned the remaining suffix for every line and
made the workload quadratic.

Limit decoding on either side to the 20 bytes that can contribute those
five codepoints.  This reduces the Little Prince C API benchmark from about
50 ms to 8 ms and the English word-list benchmark from 266 ms to 13 ms.

Tested with cargo test --workspace.

Assisted-by: OpenAI Codex <codex@openai.com>
Return a mutable pointer to the static array of const strings so cbindgen
emits HarfBuzz's const-char double-pointer declaration. The ABI was already
compatible, but the extra outer const qualifier broke source compatibility.

Test the exact generated declaration to keep the compatibility header usable
with code written against HarfBuzz.

Assisted-by: OpenAI Codex <noreply@openai.com>
Use Buffer::push_str for valid UTF-8 instead of growing the buffer one
codepoint at a time. Adjust the generated relative clusters to retain the
C API's absolute-offset convention.

Assisted-by: OpenAI Codex <noreply@openai.com>
Cargo suppresses final LTO when an rlib is emitted alongside staticlib and
cdylib outputs. The result costs about 17 percent on long shaping workloads.

Keep the C API crate limited to its two C artifacts and run its API and
header checks through the unit-test harness, which still gets a Rust test
artifact without affecting release output.

Assisted-by: OpenAI Codex <noreply@openai.com>
Building the FontInstance shaper view for every C API shape call rebuilt
the OpenType and AAT table views and glyph metrics. This dominated the
overhead when shaping many short buffers.

Build the prepared shaper whenever the font instance changes and reuse it
for subsequent shape calls. Font callbacks already may not mutate or free
their font while shaping, so borrow the callback state directly instead of
cloning per-call snapshots.

Roboto en-words improves from 7.83 ms to 6.92 ms and is now faster than
the 7.95 ms HarfBuzz-HarfRust bridge path.

Assisted-by: OpenAI Codex <noreply@openai.com>
Asking hr_font_set_funcs for no callbacks now means no callbacks, which
left nothing that put the built-in ones back. HarfBuzz has
hb_ot_font_set_funcs for exactly that and this is its counterpart: it
installs the callbacks a font starts with, and releases whatever data
the callbacks it replaces were given, as replacing them with others
does.

Verified against HarfBuzz 14.4.0 through the same C: shaping and the
glyph getters both answer from the font's tables again afterwards, and
the replaced font data is released once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfrg

dfrg commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author
  • set_funcs(NULL) now installs nil funcs, so shaping such a font gives .notdef for everything. This reverses a deliberate choice of mine — a test asserted that clearing the funcs object restored the built-in behaviour — and it removes the only way back to the built-in callbacks, which is exactly the position a HarfBuzz caller is in.

We have hb_ot_set_funcs() for that.

Added this, thanks.

@behdad

behdad commented Sep 9, 2026

Copy link
Copy Markdown
Member

Hi — Codex here (OpenAI’s coding agent). I reviewed the follow-up comment and the 13 commits through 6147c69a.

The narrow reproductions behind the original eight findings are addressed, and the append, callback dispatch, destructor reentrancy, unset-property, empty-shaper-list, and shape-plan changes look sound. However, I do not think the branch is merge-ready yet:

  1. P1 — Glyph serialization without allocated positions is broken. At harfrust-capi/src/buffer.rs:1354, the wrapper calls a serializer that zips glyph infos with the positions slice. When positions have never been allocated, a two-glyph buffer returns items=2, consumed=0, text="". Serializing a sub-range additionally aborts at line 1362 because it calls replace_range(0..1, "|") on that empty string. HarfBuzz 14.4.0 serializes the IDs normally: [gid1=0|gid2=1].

  2. P1 — hr_buffer_add_codepoints now incorrectly validates its input. At harfrust-capi/src/buffer.rs:653, it still delegates to the newly validating UTF-32 implementation. In a probe using 41,D800,110000,42, HarfRust produced 41,FFFD,FFFD,42; HarfBuzz 14.4.0 preserved 41,D800,110000,42. HarfBuzz explicitly makes add_codepoints the non-validating counterpart to add_utf32, and the new test’s own comment says the same, but does not test it.

  3. P1 — guess_segment_properties still omits the process-default language. harfrust/src/hb/buffer.rs:902 ends with TODO: language must be set, and the C wrapper simply calls it. With LC_ALL=C, HarfRust reports a NULL language while HarfBuzz 14.4.0 reports c. This can affect language-specific shaping, not only property round-tripping. The C API already has a thread-safe hr_language_get_default, so the stated locale concern does not require leaving this mismatch.

  4. P2 — Sub-range serialization with NO_ADVANCES loses the advances before start. Building a sliced buffer at harfrust-capi/src/buffer.rs:1340 resets the accumulated pen position. With glyph 0 having advance 10 and glyph 1 offset 2, serializing glyph 1 produced @2,0; HarfBuzz 14.4.0 produced @12,0.

  5. P2 — Unavailable glyph extents are emitted as zero extents. harfrust/src/hb/buffer.rs:2189 uses unwrap_or_default(). With a NULL font and GLYPH_EXTENTS, HarfRust emitted [gid1=0+0<0,0,0,0>]; HarfBuzz 14.4.0 omitted the unavailable field and emitted [gid1=0+0].

  6. P2 — Partial serialization remains all-or-nothing when a glyph name contains |. The fallback at harfrust-capi/src/buffer.rs:1389 infers boundaries from rendered separators and refuses every partial result if a name contains that character. HarfBuzz tracks items structurally and still emits each complete item that fits.

  7. P2 — The tests and installed documentation contradict the new behavior. The child at harfrust-capi/tests/capi.rs:1619 unconditionally panics after a normal return, and aborts() treats every non-success status as proof of an abort. Consequently, aborts("coords") still passes even though coordinate mismatches no longer abort. Also, include/hr.h:2703 says NULL font funcs restore built-ins, while the implementation now correctly installs nil funcs; the shape-plan docs and README still say differing coordinates abort.

Validation: all workspace tests passed when run with registry dependencies (including 6,065 shaping tests and 75 C-API tests), along with formatting and git diff --check. GitHub checks are green. The repository-local Cargo override currently points at an incompatible adjacent Fontations checkout, so I excluded that environment issue from the review.

— Codex

behdad commented Sep 9, 2026

Copy link
Copy Markdown
Member

Hi — I’m Codex, OpenAI’s coding agent. At Behdad’s request, I measured the binary-size and unsafe-code impact of this branch at ac10a344, against its merge base 7a6d0589.

Binary size

Measured on x86-64 Linux with rustc 1.94.0-nightly, using the repository’s stripped, fat-LTO release profile:

  • Stripped libharfrust_c.so: 1,370,888 bytes (1.31 MiB), including 1,033,456 bytes of .text.
  • The code attributable specifically to the C API layer is approximately 92,951 bytes (90.8 KiB), or 9.0% of .text. That consists of 84,263 bytes across the 160 exported hr_* symbols plus 8,688 bytes attributed to harfrust_c internals. As usual with LTO and inlining, crate attribution is approximate.
  • libharfrust_c.a is 9,687,514 bytes (9.24 MiB) as an archive. The supplied example linked statically into a 1,324,736-byte (1.26 MiB) executable, which is a more representative deployed size.
  • The existing Rust hr-shape release binary changed from 1,790,320 to 1,790,736 bytes: +416 bytes (0.023%), with its .text section increasing by 368 bytes.

So the C target’s complete standalone footprint is about 1.31 MiB, but the actual ABI-wrapper portion is about 91 KiB, and ordinary Rust consumers pay essentially nothing because the new target is opt-in.

Unsafe-code impact

The safe core remains under #![forbid(unsafe_code)], and this branch adds no unsafe operations under harfrust/src. The unsafe code is isolated to the new C-API crate.

Across its 5,978 lines of production Rust, I counted:

  • 246 explicit unsafe { ... } blocks
  • 136 unsafe C exports, out of 160 exports
  • 16 internal unsafe fn declarations
  • 13 manual unsafe impl Send/Sync declarations
  • 7 unsafe callback-function type declarations
  • 411 physical source lines containing actual unsafe syntax (6.9% of the production source)

The 136 unsafe exports mostly reflect raw-pointer C signatures rather than 136 distinct implementations of dangerous logic. The higher-value audit surface is the 246 operation blocks, the 13 Send/Sync assertions, and the Shaper<'_> → Shaper<'static> lifetime transmute in font.rs. Most of the remaining blocks are repetitive pointer validation, raw-slice construction, reference-count handling, or invocation of caller-provided callbacks.

Bottom line: the binary overhead is modest and fully opt-in; the unsafe footprint is substantial in raw count, as expected for this breadth of C API, but is cleanly confined to the FFI crate.

@behdad

behdad commented Sep 10, 2026

Copy link
Copy Markdown
Member

Hi — I’m Codex, OpenAI’s coding agent. I reviewed this C API against the reference behavior in a current HarfBuzz checkout (f5efbbef38, 14.4.0-86-gf5efbbef38). I excluded the differences documented as intentional in the README, such as read-only blobs, unsupported JSON serialization, and automatic property guessing.

I found the following behavioral and ABI bugs, ordered roughly by impact:

  1. High: some C inputs can create invalid Rust enum values, causing undefined behavior. hr_buffer_content_type_t, hr_buffer_cluster_level_t, hr_buffer_serialize_format_t, and hr_memory_mode_t are exposed as Rust #[repr(C)] enums (buffer.rs:23,62,91; blob.rs:12). HarfBuzz explicitly permits hb_buffer_serialize_format_from_string() to return an arbitrary uppercased tag, such as FOO , but hr_buffer_serialize_format_from_string() collapses it to INVALID (buffer.rs:1255). Passing such an unknown value from C into a Rust function accepting the enum violates Rust’s discriminant validity rules. These ABI types should be integer typedefs plus constants and checked conversions.

  2. High: sub-font callback inheritance is a snapshot instead of parent delegation. hr_font_create_sub_font() copies the parent’s callback object and font_data (font.rs:229-255), while unset callbacks return None or zero (font_funcs.rs:451-565). HarfBuzz gives a sub-font default callbacks which dynamically call its retained parent. This means parent callback changes after child creation are invisible, callbacks receive the child/font-data pair instead of the parent’s, parent-to-child scale adjustment is skipped, and installing a partial callback set on a sub-font disables fallback for all unset operations.

  3. High: buffer context becomes stale or is overwritten during normal appends. hr_buffer_add() does not clear post-context (buffer.rs:396-403). UTF-8 and UTF-32 additions overwrite pre-context even when the buffer is already nonempty, and do not clear post-context when the supplied item reaches the end of the text (buffer.rs:535-584,596-643). hr_buffer_set_length() also leaves context and content type stale (buffer.rs:976-984). HarfBuzz only installs pre-context when the destination is empty, always clears/rebuilds post-context, and clears content type plus contexts when length becomes zero. This can change cross-run Arabic and combining-mark shaping.

  4. High: hr_language_matches() implements the relationship backwards. It reports matches("en-US", "en") as true and matches("en", "en-US") as false (common.rs:754-771); the current test at tests/capi.rs:1348-1360 enshrines that reversed result. HarfBuzz defines the second argument as the more-specific language. Language canonicalization also retains suffixes such as .utf8 that HarfBuzz removes.

  5. High: hr_buffer_add_codepoints() incorrectly performs UTF-32 validation. It directly calls hr_buffer_add_utf32() (buffer.rs:653-660), converting surrogates and values above 0x10FFFF to U+FFFD. HarfBuzz deliberately uses its non-validating UTF-32 decoder for this API, so sentinel or non-Unicode codepoints change unexpectedly.

  6. High: glyph serialization loses data in several cases. Core serialization zips glyph infos with positions (harfrust/src/hb/buffer.rs:2152), so a glyph buffer without positions serializes zero glyphs; HarfBuzz enables NO_POSITIONS and still serializes every info. Partial-range serialization with NO_ADVANCES starts coordinates at zero instead of including preceding advances (harfrust-capi/src/buffer.rs:1340-1349). Missing extents are printed as <0,0,0,0> instead of omitted, and serialization uses the underlying FontInstance, bypassing installed font callbacks.

  7. Medium: buffer configuration is copied/reset incorrectly. hr_buffer_create_similar() copies direction, script, and language (buffer.rs:265-278), whereas HarfBuzz copies configuration but not segment properties. Conversely, hr_buffer_clear_contents() reaches a core Buffer::clear() that resets cluster level and the not-found variation-selector setting (harfrust/src/hb/buffer.rs:755-779), while HarfBuzz preserves both.

  8. Medium: the not-found variation-selector sentinel is wrong. The getter maps the internal unset state to zero and the setter stores 0xFFFFFFFF as a real glyph (buffer.rs:1102-1125). HarfBuzz defaults this property to HB_CODEPOINT_INVALID (0xFFFFFFFF), meaning unresolved selectors are removed. Both the default getter result and shaping behavior differ.

  9. Medium: face table callbacks handle TAG_NONE in the opposite places. For callback-backed faces, hr_face_reference_blob() always returns empty, while hr_face_reference_table(TAG_NONE) invokes the callback (face.rs:350-379). HarfBuzz invokes the callback with TAG_NONE to obtain the complete font blob, but public table lookup rejects TAG_NONE. Also, hr_face_create_for_tables(NULL, data, destroy) creates a live face and delays destroy; HarfBuzz returns the empty face and invokes destroy immediately.

  10. Medium: zero-length hr_blob_create_or_fail() violates the reference contract. Zero length returns NULL and invokes the destroy callback immediately (blob.rs:160-190). HarfBuzz returns a freshly allocated empty blob and retains the callback until that blob is destroyed. The implementation also omits HarfBuzz’s rejection of lengths at or above 1 << 31.

  11. Medium: segment-property comparison and overlay are incomplete. Equality ignores reserved1 and reserved2, and overlay copies script/language even after direction or script conflicts (shape_plan.rs:66-115). HarfBuzz compares both reserved fields and stops overlaying at the first mismatch. hr_buffer_append() duplicates the same unconditional overlay behavior (buffer.rs:698-710).

  12. Medium: public glyph lookup disagrees with shaping for legacy cmaps. hr_font_get_nominal_glyph() calls the raw cmap subtable directly (font.rs:624-644), bypassing HarfRust’s own MacRoman and Windows-symbol remapping in harfrust/src/hb/charmap.rs. HarfBuzz applies those mappings in its cmap accelerator. Therefore shaping can find a glyph that the public getter reports missing; default UVS lookup has the same issue.

  13. Medium: empty-buffer shaping returns the wrong success value. hr_shape_full() validates the shaper and font before recognizing an empty buffer (shape.rs:131-154), and plan execution validates the plan first (shape_plan.rs:491-526). HarfBuzz immediately returns true for an empty buffer in both paths, even when the shaper list or plan would otherwise fail.

  14. Low: feature/variation parsing and feature stringification differ. Failed parses leave the destination untouched (common.rs:819-833,910-924), while HarfBuzz zeroes it. A one-element feature range is emitted as [5:6], whereas HarfBuzz emits [5] (common.rs:843-871).

The existing cargo test -p harfrust-capi suite passes all 76 tests, so these cases are currently uncovered.

dfrg and others added 2 commits September 9, 2026 21:18
Serializing a range worked on a copy of it and then found the item
boundaries by counting separators in the result, which went wrong four
ways. A buffer that was never given positions serialized as nothing at
all, because the serializer walks glyphs alongside their positions and
there were none to walk; HarfBuzz turns the request for positions off in
that case and reports the glyphs. Asking for a sub-range of such a
buffer went further and aborted the process, running off the end of the
empty string it had just produced. With advances suppressed, each item
reports the pen position it sits at, which counts from the start of the
buffer, and working from a copy of the range lost everything before it.
Extents that cannot be worked out at all -- with no font, nothing can --
were reported as zero rather than left out. And a glyph name containing
a separator threw the boundary count off, at which point partial writes
gave up and wrote nothing.

Items are now serialized one at a time and assembled, so the boundaries
are where the items are, the pen carries across the range, and the flags
say only what can actually be answered.

hr_buffer_add_codepoints had come to validate its input, having been
written in terms of hr_buffer_add_utf32, which now replaces what are not
characters. HarfBuzz makes them counterparts: add_utf32 checks, and
add_codepoints is for callers that have already checked, or that mean
it. They share a body and differ in that.

Guessing a buffer's properties left the language unset where HarfBuzz
settles on the one the process runs under, which language-specific
shaping then follows. harfrust leaves it alone, having no business
reading the environment, so the C API fills it in from the same default
hr_language_get_default reports.

Found by Codex on #460. Verified against HarfBuzz 14.4.0 through the
same C, including the sweep of every destination size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The abort tests ran a case in a child process and read any unsuccessful
exit as proof it had aborted -- but the child ended in unreachable!(),
which panics, so a case that quietly returned looked exactly like one
that stopped the process. aborts("coords") had been passing since
coordinate mismatches stopped aborting, testing nothing. The child now
exits with a status of its own when the call returns, and the case that
no longer aborts asserts that it does not, which is what shows the check
can tell the difference.

The documentation had drifted the same way: hr_shape_plan_execute and
the README still said a plan built for other variation settings aborts,
and the README still said hr_shape_full returns false when a buffer runs
past its limits. Neither has been so for several commits.

Also covers the fixes in the previous commit: a buffer with no
positions, a range serialized with advances suppressed, extents nothing
can answer, add_codepoints passing its input through, and guessing
settling on a language.

Found by Codex on #460.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfrg

dfrg commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

All seven addressed and pushed (33f1bb1, e7063f3). Same method as before: each finding got a C probe compiled twice, once against <hb.h> and once against our C API through hr-hb.h, so the verdicts and the fixes are both observed differences rather than code readings.

# HarfBuzz Us before Now
1 [gid1=0|gid2=1] "", and the sub-range aborted the process match
2 41 D800 110000 42 41 FFFD FFFD 42 match
3 language=c (null) match
4 @1114,0 / @2264,0 no offset / @1150,0 match
5 [gid1=0] "" match
6 per-item all-or-nothing fixed structurally
7 — vacuous test, stale docs fixed

Findings 1, 4, 5 and 6 turned out to be one root cause. Serializing a range worked on a copy of it and then found item boundaries by counting separators in the output. Serializing one item at a time and assembling them fixed all four at once: the boundaries are where the items are, the pen carries across the range, and the flags now say only what can actually be answered — positions turned off when the buffer has none, extents dropped when there is no font to ask. Worth noting #1 was worse than reported: the sub-range path didn't just return nothing, it aborted the process running off the end of the empty string it had produced.

Finding 3 had a knock-on you should know about. Once guessing sets a language, a plan built from hand-written hb_segment_properties_t with a NULL language no longer matches a guessed buffer, and hb_shape_plan_execute trips the properties assertion — HarfBuzz asserts identically there, so this is faithful, but it will surprise callers who build props by hand. Our test helper now carries the default language, as a real caller's props would.

Finding 7 was the most useful one. aborts() read any unsuccessful exit as proof of an abort, and the child ended in unreachable!(), which panics — so aborts("coords") had been passing while testing nothing ever since coordinate mismatches stopped aborting. The child now exits with a status of its own when the call returns, and the coords case asserts that it does not abort, which is what demonstrates the check can tell the two apart. I also found one stale doc the review missed: the README still said hr_shape_full returns false when a buffer runs past its limits, which stopped being true several commits ago.

One residual on #5. Extents are omitted when there is no font at all, but if a real font cannot produce extents for a particular glyph we still write <0,0,0,0>. That one lives in harfrust's serializer (unwrap_or_default) rather than in the C API, so I left it rather than change the core crate for it.

Verification: 81 C-API tests (up from 76), 6065 shaping tests, hr-shape, cargo fmt and clippy clean, and every earlier probe re-run — the 74-destination-size serialize sweep and the 8464-sequence UTF-8 sweep are still byte-identical, and the buffer-append probe now matches too, since the language fix closed its last difference.

dfrg and others added 3 commits September 9, 2026 21:50
hr_buffer_content_type_t, hr_buffer_cluster_level_t,
hr_buffer_serialize_format_t and hr_memory_mode_t were Rust enumerations
carried across the boundary, so a caller passing anything but the exact
values named in them was undefined behaviour rather than merely wrong.
HarfBuzz invites precisely that: serialize_format_from_string hands back
whatever tag it was given, named or not, and that value is meant to come
back in.

They are integer typedefs with constants now, as the flags already were,
and the conversions answer for values that name nothing: an unknown
content type says as little as the invalid one, an unknown cluster level
leaves the buffer as it starts, and an unknown format serializes nothing
and has no name. Reading a format from a string is now what HarfBuzz
does, the tag with the case bit cleared, so a name this library does not
know comes back as itself instead of collapsing to invalid.

Found by Codex on #460.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sub-font copied its parent's callbacks and font data when it was made,
so callbacks installed on the parent afterwards were invisible to it,
the callbacks it did run were handed the child and the child's data
rather than the parent's, and a partial set installed on the child
turned off everything the parent could still have answered.

A sub-font now carries nothing of its own and every dispatch walks the
chain: the nearest font whose callbacks answer for a kind answers, gets
handed its own font pointer and data, and distances come back rescaled
between the two fonts' scales. A chain with callbacks nowhere falls to
the font's own tables, and one with callbacks but not this kind reports
nothing available -- which for an advance means the font's own scale, as
HarfBuzz answers, and not zero.

The public glyph getters go through the same walk, so a font answers the
same whichever way it is asked, and their built-in path now reads the
charmap shaping reads rather than the raw cmap subtable, which knows
about the Macintosh Roman and Windows symbol encodings. That needs
harfrust to hand out the built-in callbacks, which it now does through
Shaper::builtin_font_funcs.

Serializing asks the same way, so extents a caller's callback answers
for are the extents that get written.

Replacing a parent's callbacks now releases the data they were given
even while a sub-font is alive, because the sub-font no longer holds it.
Confirmed the same in HarfBuzz.

Found by Codex on #460, all of it probed against HarfBuzz 14.4.0 through
the same C.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine more places where the C API and HarfBuzz disagreed, each confirmed
by probing both through the same C.

Context belonged to whatever had been added last rather than to the text
it came with: adding a codepoint left the old post-context in place,
adding text installed its pre-context even onto a buffer that already
held some, and an item running to the end of the text left the previous
post-context standing. Emptying a buffer by length kept its content type
and its context. All of that changes how the next shape joins, so the
rules are HarfBuzz's now: pre-context only onto an empty buffer, post
rebuilt every time, and a buffer emptied by length holding nothing.

hr_language_matches had the relationship the wrong way round -- the
second argument is the more specific language -- and the test enshrined
it. Canonicalizing a tag now ends at the first character that can be
neither a subtag nor a separator, so a locale's codeset and modifier are
not part of the language.

A buffer made like another copied what the source said about its text,
where HarfBuzz copies only how it is set up; clearing a buffer's
contents reset its cluster level and variation-selector fallback, which
HarfBuzz keeps. That last one is harfrust's own Buffer::clear, whose
documentation already claimed to match hb_buffer_clear_contents.

An unresolved variation selector is spelled with the codepoint that is
not one, which is also the default, rather than glyph zero.

The none tag names the whole font, so a face built from a table callback
answers hr_face_reference_blob with it and rejects it in
hr_face_reference_table, which is the opposite of what it did. A face
asked for with no callback at all is the empty face, and the data handed
over with it is released rather than held.

A zero-length blob is a blob of nothing rather than a failure, and holds
the caller's data until it is destroyed; a length that cannot be
represented is what fails instead.

Segment properties compare their reserved fields, and overlaying stops
where the two describe the text differently.

An empty buffer shapes successfully whatever else was asked for, and a
parse that fails leaves the destination describing nothing rather than
whatever the caller left in it.

Found by Codex on #460.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfrg

dfrg commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

All fourteen addressed, pushed as three commits. Two of them (#5, and most of #6) were already fixed by the previous push, which this review predates — it cites the old add_codepoints and a 76-test suite; we're at 89 now.

#1 was the one I fixed first, and the visible symptom understates it. The four #[repr(C)] enums are now integer typedefs with constants, as the flags already were. A C caller holding a value Rust didn't name was undefined behaviour, and HarfBuzz invites exactly that — serialize_format_from_string hands back any tag, meant to come back in. Reading a format from a string is now what HarfBuzz does (the tag with the case bit cleared), so an unknown name survives the round trip instead of collapsing to invalid. Making the conversions total also forced the compiler to surface every unnamed-value case, which is the real win.

#2 needed the dispatch rebuilt. A sub-font now carries nothing of its own and every lookup walks the parent chain: the nearest font whose callbacks answer does answer, gets handed its own font pointer and data, and distances come back rescaled between the two fonts' scales. Two things fell out of getting this right:

  • The adapter wasn't installed at all for a sub-font with no callbacks of its own, so it shaped with built-ins. Fixed by asking the whole chain.
  • An unanswered advance returns the font's own x_scale in HarfBuzz (-y_scale vertically), not zero. Our docs and a test both said zero. Probed directly to confirm before changing it.

#12 came along with it: the built-in path now reads the charmap shaping reads — which knows the Macintosh Roman and Windows symbol encodings — rather than the raw cmap subtable, so a glyph shaping can find is one the getter reports. That needed harfrust to hand out its built-in callbacks, which it now does through Shaper::builtin_font_funcs.

#3 changes real shaping, not just bookkeeping — the two-adds case produced different glyphs. Context now follows HarfBuzz's rules exactly: pre-context only onto an empty buffer, post-context rebuilt on every add, and a buffer emptied by length keeping neither its content type nor its context.

#4's fix had to include the test, which enshrined the reversed relationship; it now asserts that the second argument is the more specific language. Canonicalization ends at the first character that can be neither subtag nor separator, so en_US.utf8 and en-us are one language.

The rest — #7 (copy configuration, not properties; keep cluster level and the variation-selector fallback across clear_contents), #8 (the not-found sentinel is the codepoint that isn't one), #9 (the none tag names the whole font, so the two entry points were exactly inverted), #10 (a zero-length blob is a blob), #11 (compare reserved fields, stop overlaying at the first disagreement), #13 (an empty buffer shapes successfully whatever else was asked for), #14 (a failed parse describes nothing; a one-character range prints as one) — all match now.

Two notes on scope:

  • Buffer::clear changed in harfrust itself, since its own documentation already claimed to match hb_buffer_clear_contents and it didn't. Buffer::reset still resets everything, so nothing else moved.
  • One residual: hb_buffer_create_similar also copies the replacement codepoint in HarfBuzz; we have no such setting to copy.

89 C-API tests (up from 76), 6065 shaping tests, cargo fmt and clippy clean. The full sweep across all three reviews still matches, including the 74-destination-size serialize sweep and the 8464-sequence UTF-8 sweep.

@behdad

behdad commented Sep 10, 2026

Copy link
Copy Markdown
Member

Hi — I’m Codex, OpenAI’s coding agent. I reviewed the follow-up commits through 1d779d50 and reran the comparison against HarfBuzz. This is substantially better, and most of the earlier findings are fixed, but I still found four concrete issues.

  1. P1 — Sub-font callback fallback remains incorrect. The resolver in harfrust-capi/src/font_funcs.rs:464 remembers that some funcs object appeared in the chain; if that object lacks the requested callback, reaching a parent represented by funcs == NULL produces Missing instead of using that parent’s built-in callbacks. A C probe that installs only an h-advance callback on a sub-font gets found=1, glyph=68 from HarfBuzz but found=0, glyph=0 here. There is a second manifestation: after a parent nominal callback returns glyph 777, creating a sub-font and calling hb_ot_font_set_funcs(sub) makes HarfBuzz use the sub-font’s OT cmap and return 68, whereas HarfRust still delegates to the parent and returns 777. hr_ot_font_set_funcs() setting funcs = NULL at font.rs:687 cannot distinguish “OT funcs explicitly installed here” from “this sub-font has no funcs of its own and delegates.”

  2. P1 — hr_segment_properties_overlay(p, p) is unsound. At harfrust-capi/src/shape_plan.rs:111, the tuple expression creates an &mut hr_segment_properties_t and an &hr_segment_properties_t simultaneously. The API does not require distinct pointers, and hb_segment_properties_overlay(&p, &p) is legal and harmless in HarfBuzz, but it violates Rust’s reference-aliasing requirements here. The source should either special-case identical pointers before forming references or copy src through the raw pointer first.

  3. P2 — Fixing zero-length create_or_fail regressed regular blob creation. Both hr_blob_create() and hr_blob_create_or_fail() now go through the same zero-length path (harfrust-capi/src/blob.rs:142-218). They need different contracts. With hb_blob_create(..., length=0, READONLY, data, destroy), HarfBuzz returns the singleton empty blob and calls destroy before returning; HarfRust returns a freshly allocated blob and defers destroy until that blob is destroyed. The differential probe reported:

    HarfBuzz: singleton=1 destroyed_before=1
    HarfRust: singleton=0 destroyed_before=0
    

    The freshly allocated zero-length behavior belongs only to *_create_or_fail().

  4. P2 — Empty-buffer shape-plan execution is still checked too late. hr_shape_plan_execute() validates the font and plan at harfrust-capi/src/shape_plan.rs:516-538, then checks whether the buffer is empty. HarfBuzz checks the buffer length first. Using shape_plan_get_empty(), a valid font, and an empty buffer returns 1 in HarfBuzz and 0 in HarfRust.

There is also a validation failure outside those behavioral findings: cargo clippy --workspace --all-targets --all-features -- -D warnings fails. harfrust/examples/hrbench.rs:113 uses an undeclared compile-stats feature, and harfrust/examples/hrdump.rs:25,27 triggers clippy::format_push_string.

cargo test --workspace --all-features otherwise passes, including all 89 C-API tests. The remaining earlier fixes look substantially improved.

— Codex

dfrg and others added 2 commits September 10, 2026 05:28
Three local benchmarking and dumping examples went in with an over-broad
`git add`. They were never meant to be here -- one says so in its own
header -- and they are what makes clippy fail over the workspace: an
undeclared feature and a lint the crate denies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sub-font carrying no callbacks asks its parent, which left nowhere to
record that a font reads its own tables and asks nobody: hr_ot_font_set_funcs
said it by carrying nothing, which is what asking the parent looks like. So
a sub-font told to read the tables went on delegating, and a sub-font
carrying callbacks that did not cover what was asked reported nothing
available even when its parent could have answered from the tables.

The built-in callbacks are an object a font can carry now, as they are in
HarfBuzz, and the walk has three answers to give rather than two: a font
carrying that object reads its tables and nothing above it is asked, one
carrying nothing asks its parent, and one at the top of the chain answers
from its tables or reports nothing depending on whether it carries
callbacks at all. Shaping still installs no adapter for a chain carrying
only built-ins, so the plain path is unchanged.

Overlaying segment properties onto themselves is allowed and did not have
to be: the source is read before the destination is borrowed, rather than
holding a shared and an exclusive reference to one struct at once.

A blob of no length means different things to the two creators.
hr_blob_create hands back the blob that holds nothing, so the caller's
data is released before returning, since nothing will be left to release
it; hr_blob_create_or_fail makes one that can hold it and does. The
previous commit gave both the second behaviour.

An empty buffer executes any plan, the empty one included, because there
is nothing to shape and so nothing to check a plan against. The check had
been sitting behind the plan's own.

Found by Codex on #460, each probed against HarfBuzz 14.4.0 through the
same C.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfrg

dfrg commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

All four fixed, plus the validation failure, pushed as de830f4 and 9281fa7.

HarfBuzz Us before Now
1 found=1 glyph=68; 68 after ot_font_set_funcs found=0 glyph=0; 777 match
2 returns cleanly aliasing UB match
3 singleton=1 destroyed_before=1 singleton=0 destroyed_before=0 match
4 1 0 match

Finding 1 was the interesting one, and you put your finger on exactly why. "Reads its own tables" and "has nothing of its own to say" were both spelled as funcs == NULL, so they could not be told apart — which is precisely what hr_ot_font_set_funcs on a sub-font needed to say. The built-in callbacks are now an object a font can carry, as they are in HarfBuzz, and the walk gives three answers rather than two: a font carrying that object reads its tables and nothing above it is asked; a font carrying nothing asks its parent; a font at the top of the chain reads its tables or reports nothing available depending on whether it carries callbacks at all. Both manifestations you describe now match. Shaping still installs no adapter over a chain carrying only built-ins, so the ordinary path is untouched.

Finding 2 was real UB and worth catching. Fixed by reading the source through the raw pointer before borrowing the destination, so no shared and exclusive reference to one struct exist at once.

Finding 3 was a regression I introduced fixing the previous round — I gave both creators the create_or_fail contract. They are separate again: hr_blob_create hands back the blob that holds nothing and releases the caller's data before returning, since nothing will be left to release it; hr_blob_create_or_fail makes one that can hold it, and does.

Finding 4: the check was sitting behind the plan's own guard, so the empty plan failed before the buffer was ever looked at. It is now the first thing the function does.

On the validation failure: those three examples were local scratch tools that went in with an over-broad git add on my side — one of them says in its own header that it is kept out of the repository. They are gone, which removes both the undeclared feature and the format_push_string lint. cargo clippy --workspace --all-targets is clean on a fresh checkout.

97 C-API tests now, 6065 shaping tests, cargo fmt clean. The full sweep across all four reviews — 31 probes plus the 74-destination-size serialize sweep and the 8464-sequence UTF-8 sweep — matches HarfBuzz throughout.

dfrg and others added 2 commits September 10, 2026 05:55
A sweep over the exports no review had probed yet, one C file compiled
against both libraries. Five answers differed.

An empty name names no tag. hr_tag_from_string padded nothing out to four
spaces and handed back a tag of blanks, which then read back as a script
and a language of its own rather than as the absence of one. It answers
HR_TAG_NONE now, and hr_script_from_string routes through the tag so the
two agree.

A script that is not one does not say which way text runs, so
hr_script_get_horizontal_direction answers LTR for it rather than
HR_DIRECTION_INVALID. Text runs left to right unless its script says
otherwise.

Reversing a direction is the bit flip HarfBuzz spells it as. The match
arm left anything that was not a direction alone; the flip carries it to
the other half of the pair it is not in, which is what HB_DIRECTION_REVERSE
does, and reversing twice still gets back where it started.

A sub-blob fixes what it is a part of. The part cannot outlive its parent
being what it was, so making one makes the parent immutable.

Every font has a parent to stop at. A font made from a face had none and
answered with nothing; it answers with the font that is empty now, so a
caller walking up the chain terminates on a font rather than on NULL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hr-hb.h exists so that source written against HarfBuzz builds against
this library unchanged. The generator mapped every hr_ name it found,
including eight that HarfBuzz has no spelling for: hb_buffer_reset_clusters
and hb_shape_plan_get_segment_properties, which HarfBuzz does not have at
all, and six lower-case hb_direction_is_* and hb_direction_reverse, which
HarfBuzz has only as upper-case macros -- mapped below already.

Nothing written against HarfBuzz can be using those names, and anything
written against one of them would fail to build against HarfBuzz, which
is the one thing this header is for. They are excluded now, and the test
that checks the header is current knows about the same set.

Checked against HarfBuzz 14.4.0's own headers: 188 names mapped, none
that HarfBuzz lacks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfrg

dfrg commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Proactive sweep — no review needed this round

Rather than wait for the next set of findings, I mapped the C API's coverage:
160 exported entry points, 76 of them exercised by the probes written for
rounds one through four, 84 never touched. I wrote a probe over the untouched
ones — same method as before, one C file compiled twice, once against
HarfBuzz 14.4.0 and once against this library, with every verdict an observed
diff rather than a code read. Six groups: direction/script/tag strings, the
buffer reverse family and reset, blobs, face and font scale and variations,
immutability, and odds and ends.

Six divergences, all now fixed and re-verified as MATCH.

Finding 1 — an empty name named a tag of blanks.
hr_tag_from_string("") padded nothing out to four spaces and handed back
' '×4, which then read back as a script and a language of its own rather
than as the absence of one. It answers HR_TAG_NONE now, and
hr_script_from_string routes through the tag so the two cannot disagree.

Finding 2 — a script that is not one claimed no direction.
hr_script_get_horizontal_direction answered HR_DIRECTION_INVALID for an
unknown or invalid script. HarfBuzz answers LTR: text runs left to right
unless its script says otherwise, and a script that is not one does not say
otherwise.

Finding 3 — reversing a direction was a match, not a bit flip.
The match arm left anything that was not one of the four alone.
HB_DIRECTION_REVERSE is direction ^ 1, which carries a non-direction to
the other half of the pair it is not in — and reversing twice still gets back
where it started. Spelled the same way now.

Finding 4 — a sub-blob did not fix its parent.
The part cannot outlive its parent being what it was, so
hb_blob_create_sub_blob makes the parent immutable. Ours left the parent
mutable, so the parent could be reallocated out from under a live sub-blob.

Finding 5 — a font made from a face had no parent.
hr_font_get_parent returned NULL for it. HarfBuzz returns the empty font,
so a caller walking up the chain terminates on a font rather than on nothing.

Finding 6 — the compatibility header was inventing HarfBuzz spellings.
hr-hb.h exists so that source written against HarfBuzz builds against this
library unchanged. The generator mapped every hr_ name it found, including
eight HarfBuzz has no spelling for: hb_buffer_reset_clusters and
hb_shape_plan_get_segment_properties, which HarfBuzz does not have, and six
lower-case hb_direction_is_* / hb_direction_reverse, which HarfBuzz has
only as upper-case macros — already mapped separately. Nothing written
against HarfBuzz can be using those names, and anything written against one
of them would fail to build against HarfBuzz. Excluded now, in the generator
and in the test that checks the header is current. Checked against HarfBuzz
14.4.0's own headers: 188 names mapped, zero that HarfBuzz lacks.

Verification. Six new regression tests. Full sweep re-run: 31 probes plus
the two exhaustive ones (74 serialize buffer sizes, 8464 UTF-8 sequences) —
all match. cargo test across harfrust, harfrust-capi and hr-shape:
96 C-API tests passing, workspace green; clippy clean; cargo fmt applied;
hr.h unchanged (no signatures moved).

Two differences remain intentional and documented: one shaper (ot) where
HarfBuzz lists three, and no JSON serialization format.

Pushed as 4686696 and aed0ee7.

dfrg and others added 3 commits September 10, 2026 06:33
Found by running HarfBuzz's own shaping corpus through both libraries: the
6054 cases the test suites are generated from, driven through the same C
against HarfBuzz and against harfrust-capi, and compared with everything
turned on -- glyph names, clusters, positions, advances, flags and extents.
The corpus itself asks for none of the last three in most cases, so nothing
had been comparing them.

A glyph past the last one the face has had an advance. hmtx repeats its
final entry for glyphs beyond the long-metric count, which is right up to
the glyph count and wrong past it: a malformed cmap can point shaping at a
glyph the face does not have, and HarfBuzz gives that no advance rather
than the last one it has. A face carrying no horizontal metrics at all
still gives every glyph the default, in range or not, which is also what
HarfBuzz does. Both spellings of the lookup, one glyph at a time and the
batch shaping uses, now go through one place that says this once.

Glyph extents began at the bounding box rather than at the side bearing.
There is an undocumented rasterizer behaviour HarfBuzz matches, and says
so in a comment: the glyph is shifted left by (lsb - xMin), so the ink
starts at the left side bearing. Faces where the two agree saw nothing;
faces where they do not were off by anything from one unit to 258. While
here, the box is no longer assumed to be stored the right way round.

What remains between the two is extents the tables cannot answer for yet:
CFF and CFF2 outlines, sbix and CBDT bitmaps, COLR, and any glyph in a
variable font once coordinates are set. Everything else -- every glyph,
cluster, position, advance and flag in all 6051 comparable cases -- now
matches HarfBuzz f5efbbef3 exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by walking every glyph of all 502 test faces through both libraries
and comparing what the built-in callbacks say -- advances, origins, extents
and names -- rather than only what shaping happens to ask them for.

A character the cmap sends to .notdef is a character the font does not
have. The lookup handed back glyph 0 and said it had found one, so a caller
asking whether a font covers a character was told yes for 3282 characters
across three faces that it does not cover. It matters to shaping too: a
buffer told to use its own not-found glyph never got to, because nothing
was ever not found. HarfBuzz reads a .notdef mapping as no mapping.

The ascender came from OS/2 whether or not OS/2 said to. The typographic
metrics are the ones to use only when the face sets USE_TYPO_METRICS;
otherwise hhea carries them, and a face with neither is guessed at four
fifths of the way up the em. Reading OS/2 whenever it was present, which is
nearly always, gave a different vertical advance or origin for 21718 and
42394 glyphs. The ascender is also taken as positive and the descender as
negative whichever way the face stores them, as HarfBuzz takes them.

A glyph past the last one the face has had a vertical advance, the same way
it had a horizontal one before the previous commit, and for the same
reason: vmtx repeats its final entry, which is right up to the glyph count
and wrong past it.

Two vertical cases move from passing to failing, and are excluded through
the generator's own list alongside the two already there for the same
reason. Both are variable fonts whose vertical origin HarfBuzz reads from
variable glyf extents, which HarfRust does not have yet: they matched
before only because the ascender was being read from the wrong table and
these two faces happen to put their typographic ascender exactly where the
origin belongs. The gap they point at is real and was already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The C API could install a callback for a glyph's advance, its vertical
origin and its extents, and then had no way to ask for any of them. Only
the two glyph lookups had getters. A client measuring text without shaping
it -- which is what hb_font_get_glyph_h_advance is for, and what Chrome
reaches for -- had nothing to call, and nothing outside shaping could be
compared against HarfBuzz at all.

Eighteen entry points, each one HarfBuzz's:

  hr_font_get_glyph                         the two lookups in one
  hr_font_get_glyph_h_advance               and _v_advance
  hr_font_get_glyph_h_advances              and _v_advances, over strides
  hr_font_get_glyph_h_origin                and _v_origin
  hr_font_get_glyph_extents
  hr_font_get_glyph_advance_for_direction   and the plural
  hr_font_get_glyph_origin_for_direction    and add_ and subtract_
  hr_font_get_glyph_extents_for_origin
  hr_font_get_glyph_name                    and _from_name
  hr_font_glyph_to_string                   and _from_string

They answer through the same walk up the callback chain shaping uses, so a
font cannot say one thing to a caller and another to the shaper: the
advance and origin dispatch that lived inside the shaping trait moved into
call_ methods both go through, as the glyph lookups and extents already
did.

Which turned up a scaling bug. Extents read from the font's own tables came
back in design units while everything else came back in the units
hr_font_set_scale asks for. Nothing had caught it because hb-shape sizes a
font at its own upem, where the two are the same. Scaling is HarfRust's
own now -- exported as Scale, so the getters and shaping round identically
rather than each spelling HarfBuzz's arithmetic separately.

Glyph names come from the face through GlyphNames, now public, and a name
outlives the lookup that found it so a caller can hold one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfrg

dfrg commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Differential shaping and font funcs against HarfBuzz

Two new harnesses, each one C file compiled twice — once against HarfBuzz,
once against harfrust-capi — so every verdict is an observed diff rather
than a code reading.

Correction to my earlier rounds: my HarfBuzz checkout was ten days
stale. The first differential run showed seven divergences and all seven
were artifacts of that — the missing commits were [ot] Preserve pair-positioning and ligature concat hazards and [gsubgpos] Preserve concat hazards at end of input. Everything below is against HarfBuzz
f5efbbef3 (2026-09-09), built without the embedded Rust shaper since that
now needs nightly. All 31 earlier API probes plus the two exhaustive sweeps
were re-run against it and still match, so the four review rounds hold up.

Harness one: the shaping corpus

The 6,054 cases the generated shaping suites are built from — font, text,
options and expected output — driven through both libraries with
hb-shape's own setup (util/shape-options.hh). Validated against
hb-shape itself first.

Run as the suites run it, HarfBuzz and harfrust agree everywhere. But most
corpus cases pass --no-positions --no-clusters --ned, so advances,
positions and extents had never been compared. Re-run with everything on —
names, clusters, positions, advances, flags, extents — two divergences fell
out, both fixed:

Finding 1 — a glyph past the last one the face has had an advance.
hmtx repeats its final entry beyond the long-metric count, which is right
up to the glyph count and wrong past it. A malformed cmap points shaping at
exactly such glyphs (the aots cmap4 fonts do); HarfBuzz gives them no
advance, harfrust gave 1500.

Finding 2 — extents began at the bounding box, not the side bearing.
HarfBuzz emulates an undocumented rasterizer behaviour and says so in a
comment: the glyph is shifted left by (lsb - xMin). Divergences ran from
one unit to 258. The box is also no longer assumed to be stored the right
way round.

Harness two: font funcs, every glyph of every face

Filling in the getter surface (below) unblocked walking all 502 test faces
glyph by glyph — advances, origins, extents, names, plus a slice of Unicode
through the cmap. 105,655 glyph rows. Three more divergences:

Finding 3 — a character the cmap sends to .notdef was reported as
covered.
The lookup handed back glyph 0 and said it had found one, so a
caller asking whether a font covers a character was told yes for 3,282
characters across three faces that do not cover them. It reaches shaping
too: a buffer given its own not-found glyph never got to use it, because
nothing was ever not found.

Finding 4 — the ascender came from OS/2 whether or not OS/2 said to.
The typographic metrics apply only when the face sets USE_TYPO_METRICS;
otherwise hhea carries them, and a face with neither is guessed at four
fifths of the em. Reading OS/2 whenever present — nearly always — gave a
different vertical advance for 21,718 glyphs and a different vertical
origin for 42,394. Ascender and descender are now also taken as positive
and negative whichever way the face stores them.

Finding 5 — the vertical advance had finding 1's bug too, vmtx
repeating its final entry past the glyph count.

A trade worth flagging. Finding 4 moves two corpus cases from passing
to failing (vertical_009, vertical_010). Both are variable CJK fonts
whose vertical origin HarfBuzz reads from variable glyf extents. They
passed before only because the ascender was being read from the wrong table
and these two faces happen to put their typographic ascender exactly where
the origin belongs — right answer, wrong reason, twice. I excluded them
through the generator's own ignore list, alongside the two already there
citing the same PR. Aggregate: 24,282 glyph slots corrected against two
cases where an existing gap stopped being masked. Say the word if you'd
rather I revert it and keep the two green.

Filling in the getter surface

The C API let you install a callback for a glyph's advance, vertical origin
and extents, then gave you no way to ask for any of them — only the two
glyph lookups had getters. Eighteen entry points added, each HarfBuzz's:
hr_font_get_glyph, _glyph_h_advance / _v_advance and the strided
plurals, _glyph_h_origin / _v_origin, _glyph_extents, the four
_for_direction helpers plus add_ and subtract_,
_glyph_extents_for_origin, _glyph_name / _from_name, and
glyph_to_string / _from_string.

They answer through the same walk up the callback chain shaping uses — the
advance and origin dispatch moved out of the shaping trait into call_
methods both go through — so a font cannot tell a caller one thing and the
shaper another.

Which exposed a sixth bug. Extents read from the font's own tables came
back in design units while everything else came back in the units
hr_font_set_scale asks for. Nothing caught it because hb-shape sizes a
font at its own upem, where the two coincide. Scaling is now harfrust's
own, exported as Scale, so getters and shaping round identically instead
of each spelling HarfBuzz's arithmetic separately.

Where it stands

  • Shaping corpus, as the suites run it: 6,052 of 6,054 identical (3 skipped
    needing FreeType funcs, which harfrust has no equivalent for).
  • Shaping corpus, everything turned on: identical on every glyph, cluster,
    position, advance and flag; extents aside, 2 differ — the two vertical
    cases above.
  • Font funcs, 105,655 glyph rows: 48,490 differing fields, of which 98.6%
    is CFF extents and the vertical origin that falls out of them, and the
    rest COLR and bitmap extents — the static-glyf-only limitation you're
    already working on. Nothing else, apart from 24 slots where the vertical
    origin of an out-of-range glyph differs on one vmtx+glyf face; I left
    that one alone as it's entangled with the same extents path.
  • Compat header: 206 names mapped, none that HarfBuzz lacks.
  • 6,063 shaping tests, 102 C API tests, 259 others: green. Clippy clean.

Pushed as 9fa3102 and ff82026.

@behdad behdad changed the title DON'T MERGE: Add a C API Add a C API Sep 10, 2026
@behdad
behdad self-requested a review September 10, 2026 18:01
dfrg and others added 2 commits September 10, 2026 14:34
One less kebab-case crate name to carry. The directory keeps its own name,
so every path that spells it with a dash stays as it is, and the C library
this builds was already harfrust_c through an explicit [lib] name, so
nothing linking against it moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The directory kept the dash after the package lost it. Everything that
spelled the path follows it: the workspace member list, the compatibility
header generator, both example build lines, and the regenerate command the
header carries in its own banner.

The C library is still harfrust_c, through the explicit [lib] name, so
nothing linking against it moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfrg
dfrg merged commit 7507fc2 into main Sep 10, 2026
3 checks passed
@dfrg
dfrg deleted the c-api branch September 10, 2026 18:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants