diff --git a/CHANGELOG.md b/CHANGELOG.md index e819a80..7d6b48a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,310 @@ GitHub issues and pull requests. sigilbuzz is pre-1.0, so a minor release may ch the API. See [docs/STABILITY.md](docs/STABILITY.md) for what is covered by the stability commitment. +## 0.24.0 (2026-10-05) + +The crates in this release: `sigilbuzz` 0.24.0, `sigilbuzz-capi` 0.5.0, `sigilbuzz-cli` +0.3.0, `sigilbuzz-gpu` 0.3.0, `sigilbuzz-hyphen` 0.3.0, `sigilbuzz-paint` 0.4.0, +`sigilbuzz-pdf` 0.4.0, `sigilbuzz-render` 0.11.0, `sigilbuzz-subset` 0.14.0, +`sigilbuzz-svg` 0.4.0, `sigilbuzz-text-layout` 0.3.0, and `sigilbuzz-woff` 0.3.3. +Companion crates that take `sigilbuzz` types in their API move with the core crate's +minor version. `sigilbuzz-woff` exposes no `sigilbuzz` types and takes a patch release +to require `sigilbuzz` 0.24.0. Only `sigilbuzz-paint`, `sigilbuzz-render` and +`sigilbuzz-subset` change code of their own. + +Changes that can break a build, or change output with default settings, each have a +bullet under Changed: the default cluster level, `Font::coords`, +`MultiVarStore::resolve_deltas`, `GeneralCategoryClass`, the core crate's first `unsafe` +code and its need for compare-and-swap atomics, VARC tables that do not parse, the JPEG +decoder's limits, CFF2 instance output, and the work budgets that make some hostile +fonts fail. + +Changed: + +- The default cluster level is now `MonotoneGraphemes`, HarfBuzz's default (#288, #292). + `ClusterLevel::default()`, and so every new `Buffer`, was `MonotoneCharacters`. Under + the defaults, a combining mark, a variation selector, the second regional indicator of + a flag, the rest of a ZWJ emoji sequence and the other characters a grapheme merges + now share the cluster of the character they continue, as in HarfBuzz. Glyphs and + positions do not change. Code that expects a cluster per character calls + `buffer.set_cluster_level(ClusterLevel::MonotoneCharacters)`. The C API already + defaulted to `HB_BUFFER_CLUSTER_LEVEL_MONOTONE_GRAPHEMES` and is unchanged. On 554 + cases built from known consumers' fonts and strings, clusters under the defaults now + match HarfBuzz 14.5.0 in all 554. +- `Font::coords` is now `pub fn coords(&self) -> &[f32]`: the slice borrows the font + instead of living for `'a`, and the method is no longer a `const fn` (#289, #295). A + font built with `Font::with_variations` owns its coords. Code that keeps the slice + after dropping the `Font` copies it first. `Font::new` is still `const`. +- `MultiVarStore::resolve_deltas` keeps its signature but reads a delta set as one tuple + per region, region after region, as the spec, fontTools and HarfBuzz lay it out, and + ignores values past the last tuple (#291, #293). It read the values of each output in + turn, so a set varying two or more values over two or more regions resolved wrong. A + delta set that ends before it fills its tuples now adds the values it holds, as + HarfBuzz reads it, where it added nothing, and a run cut short ends its tuple after + its control byte (#296, #298). Semver tools cannot see this: a caller that encoded + delta sets value by value gets different numbers. VARC components of a crafted font + with short delta sets sat up to 100 units from HarfBuzz's. +- `unicode::general_category::GeneralCategoryClass` has a `Symbol` class (Sc, Sk, Sm, + So) and is `#[non_exhaustive]`, so a `match` on it outside the crate needs a wildcard + arm (#288, #292). `general_category_class` returns `Some(Symbol)` for the symbols, + which were `None`. The table grows by 242 ranges. +- The core crate has `unsafe` code for the first time: `OnceBox` in `src/sync.rs`, about + 40 lines with `SAFETY` comments, modeled on `once_cell::race::OnceBox` (#289, #295). + It is an `AtomicPtr` set once by compare-and-swap, through which a `Font` builds its + caches without locks. The crate needs a target with pointer-width atomics and + compare-and-swap (`cfg(target_has_atomic = "ptr")`), which `alloc::sync::Arc` already + needed, so `thumbv6m-none-eabi` does not build, as in 0.23 (#297, #301). The crate + still has no dependencies and builds under `no_std`, and `Font`, `Face` and `Blob` + stay `Send + Sync`. +- A `VARC` table that does not parse, such as one in the MultiItemVariationStore layout + HarfBuzz adopted after 14.5.0, counts as absent, as HarfBuzz 14.5.0's sanitizer drops + it (#296, #300). Outlines, `GlyphOutlines`, and the glyph extents shaping reads for + fallback mark positioning and vertical origins come from `glyf`, CFF or CFF2. Every + glyph of such a font used to fail to draw, shaping failed on fallback-positioned + marks, and vertical origins fell back to the ascender. `Face::varc` still reports the + error. Reading the new layout is #299. +- `script_of` returns the bucket of a character's Unicode Script property for every + bucket (#288, #292). The older buckets, Latin through Modi, covered the Unicode blocks + of their scripts, so letters outside those blocks were `Other`: Latin Extended + Additional, Arabic Extended-A, -B and -C, the Arabic mathematical letters, the CJK + extensions past B and the compatibility ideographs, the Mongolian Supplement, + Devanagari Extended and others. They now take their script's bucket. The Common and + Inherited characters inside the blocks took the block's bucket and are now `Other`. + These include digits, punctuation, the space, the tatweel, the dandas and combining + marks, so `script_of('0')` was `Latin`. The Coptic letters of the Greek and Coptic + block are `Other`, not `Greek`. +- Script runs (`Buffer::script_runs` and the segmentation of `shape`) and + `Buffer::guess_segment_properties` skip unassigned and private-use characters, as well + as Common and Inherited ones, as `hb_buffer_guess_segment_properties` does (#288, + #292). A private-use icon inside Latin text stays in the Latin run. The ordinal + indicators U+00AA and U+00BA count as Latin letters, so inside text of another script + they start a Latin run when the buffer has no script set. +- The vertical default for Mongolian, used when no direction is set, applies to text + that starts with a Mongolian character of the Mongolian block (#288, #292). Text that + starts with a quotation mark, the Mongolian comma, or a letter of another script stays + horizontal, as HarfBuzz lays out all text with no direction. Set `Direction::Ttb` for + vertical Mongolian. +- A `Font` keeps what HarfBuzz keeps per face and font, from its second shaping call on: + per-lookup glyph digests, so lookups and subtables that cannot apply are skipped + unparsed; the language system each script, language and FeatureVariations record + resolves to; the merged lookups of each shaping stage; and per-glyph vertical origins + and advances (#289, #295). One Noto Sans KR syllable shapes in about 4 us instead of + 62, one Segoe UI character in 3 instead of 9, one Devanagari character in 13 instead + of 400. Clones made after a font's first call share the caches, which are thread-safe + without locks. A font's first call builds none of it, and subtables are parsed only + when a glyph reaches them there too. Output does not depend on the caches. +- What a `Font` keeps is bounded (#295, #297, #301). For each of GSUB and GPOS: at most + twice the table's length plus 64 Ki subtable digests of 24 bytes, however many lookups + the table lists, with the lookup indices that name one lookup sharing its digests; a + lookup the budget cannot pay for keeps no digest and is only skipped less. The + language systems of at most 16 combinations of script, language and FeatureVariations + record per table; a run with another combination looks its features up in the font as + before. Three per-glyph caches of at most 4,096 four-byte entries each, whatever the + glyph count. A lookup of thousands of subtables costs about what it did in 0.23.1, + within about 10%. +- A variable `Font` keeps each glyph's varied advance, from `HVAR`, `VVAR` or phantom + points, from its second call on, as HarfBuzz keeps an advance cache, so a run no + longer walks the variation store for every glyph of every call (#297, #301). 100 + characters of Source Serif 4 VF at wght 700 shape left to right in 62 us instead of + 119 us in 0.23.0, and Hahmlet at wght 700 in 22 us instead of 90 us. +- Repeated top-to-bottom runs on one `Font` are faster than in 0.23.0: a CFF run about + as fast (Source Code Pro, 100 characters: 9.5 to 8.6 us), CFF2 and varied `glyf` runs + two to three times as fast (Source Serif 4 VF at wght 700: 77 to 24 us; Hahmlet at + wght 700: 67 to 20 us) (#289, #295, #297, #301). A font's first top-to-bottom call is + slower than in 0.23.0 for fonts without `VORG`, whose vertical origins come from glyph + extents since 0.23.1. +- The Indic, Khmer, Myanmar and USE syllable grammars are compiled once per process + instead of once per call (#289, #295). +- `Face::glyph_outline_at_coords` and `GlyphOutlines` draw a VARC glyph in one walk that + resolves each composite once per set of coords, composes transforms top down in + HarfBuzz's order, and draws each leaf glyph once (#291, #293, #296, #298, #300). The + walk shares one budget across every composite the glyph reaches: 2^20 units of work + for component records, axis values, condition offsets, coord vectors, region axes and + deltas, and 65,536 condition visits. Its leaves, the glyphs it draws from `glyf` or + CFF, share HarfBuzz's 2^24-unit glyph budget for their `gvar` tuple work, glyph + visits, points and charstring operations. Past a budget, the composite being read and + every later one ends and a leaf draws nothing, as in HarfBuzz, and the walk reads no + more component records than it can still draw. Every crafted glyph tested draws in + under 24 ms. The 2,048-component, 2^20-op and 64-level caps still fail the glyph with + `Malformed`. +- `sigilbuzz-render` 0.11.0: `decode_jpeg` rejects a stream with a second frame (as + libjpeg does), a frame whose blocks outnumber eight per byte of entropy-coded scan + data (COM, APPn and other padding no longer count), and a frame past 2^22 pixels + (2048 x 2048), each as `RenderError::BadJpeg` (#291, #293). The scans of a stream may + visit at most 1024 coefficients per block of its frame, more than any valid script + needs. JPEG data in fonts is sbix glyph images, far below these limits. +- `sigilbuzz-subset` 0.14.0: a full CFF2 instance rounds each blended value to a whole + unit, as HarfBuzz's flattener does, and starts each charstring at its Private DICT's + `vsindex` (#290, #294). Its charstrings equal HarfBuzz's, and Source Serif 4 VF at + wght 700 drops from 1.07 MB to 0.65 MB (its `CFF2` from 614 KB to 203 KB, HarfBuzz + 203 KB). +- `sigilbuzz-subset`: a CFF2 instance, full or partial, recomputes its left side + bearings, `head` box and `hhea` extremes from its outlines, as HarfBuzz does (#290, + #294). They kept the source's, so 1,375 of Source Serif 4 VF's 1,464 side bearings + were off at wght 700. Measuring takes time: an instance of Noto Sans KR VF (24,731 + glyphs) takes 0.26 to 0.34 s instead of 0.16 to 0.25 s. +- `sigilbuzz-subset`: the CFF and CFF2 subsetter runs each kept glyph through its + subroutine calls, as HarfBuzz's subsetter does, and fails a charstring whose + subroutines nest more than 10 deep or reach themselves, that stacks more than 513 + operands, or that takes more than 64 tokens per byte of the table plus 2^22, a `blend` + charging a token per value it leaves (#290, #294). Its memory follows the subroutines + the kept glyphs reach, and the Font DICTs of a CID-keyed font that name one Private + DICT share one read of it. The public per-body helpers `scan_subr_calls`, + `compute_kept_subrs` and `renumber_charstring` are unchanged. +- `sigilbuzz-subset`: a full CFF2 instance fails a charstring that stacks more than 513 + operands, the CFF2 limit, and its bake charges a token per value each `blend` leaves + (#290, #294). Seven crafted fonts that 0.23.1 instanced now fail, four on the budget + and three past 513 operands; HarfBuzz's instancer drops the `CFF2` table of six of + them. Crafted fonts that repeat blends without regions fail the budget in under a + second, and real fonts spend under 2% of it. +- `sigilbuzz-subset`: a renumbered subroutine call that does not fit its push is written + in its shortest form instead of failing the subset with "cannot pad operand to + original width", and a CFF2 subset no longer keeps every subroutine when that happens + (#290, #294). The Source Sans 3 VF fixture's "Hello" subset drops from 12,368 to 2,588 + bytes. +- `sigilbuzz-subset`: `subset` leaves out a `VARC` that does not parse, with a warning, + and the glyphs keep their `glyf` or CFF outlines, as the face reads them (#296, #300). + The whole subset failed before. The output equals the subset of the font without + `VARC`. +- `sigilbuzz-cli` 0.3.0: `sigilbuzz shape` builds its buffer at the default cluster + level, so its `cluster=` values change as described above and now match `hb-shape` + (#288, #292). + +Added: + +- `Font::with_variations` binds a variable instance from user-space axis values, as + HarfBuzz's `hb_font_set_variations` does: each `(tag, value)` sets every axis with + that tag, a later pair wins, unknown tags are ignored, and the values are clamped and + go through `fvar` and `avar` with HarfBuzz's rounding (#289, #295). The font owns the + coords it computes. On 4,200 random settings over every variable font tested, the + coords equal HarfBuzz's. +- `Face::glyph_outlines` returns a `GlyphOutlines` that draws many glyphs at one + instance with the outline tables read once, the `CFF2` table with its variation store, + Private DICTs and region scalars included (#289, #295). `outline` returns each glyph + as `Face::glyph_outline_at_coords` draws it, errors included, and `draw` streams into + any `OutlineSink`. Against `glyph_outline_at_coords`, a glyph of Source Serif 4 VF + takes about 3.7 us instead of 5.1, and one of Noto Sans KR VF 2.1 us instead of 3.0. +- `Varc::composite_with_font_coords` resolves a VARC glyph reached through another + composite: its own coords and the font's are given separately, so + `RESET_UNSPECIFIED_AXES` components start from the font's coords (#291, #293). It + charges its work to the 2^20-unit budget, and it gives no components for a glyph the + coverage names past the end of the glyph records, which HarfBuzz draws as nothing + (#296, #298). +- `sigilbuzz-subset`: `instance_user` instances a variable font at axis values in user + units, given by axis tag, as HarfBuzz's instancer takes them (#290, #294). `AxisLimit` + pins an axis at a value (clamped to its range) or at its default, keeps it, or takes a + HarfBuzz-style range; a range that would narrow an axis or move its default returns + `SubsetError::Unsupported`. Axes the input does not name are pinned at their defaults, + or kept with `keep_unnamed_axes`. Build the input with `UserInstanceInput::new()` and + its `with_` methods; both types are `#[non_exhaustive]`. Callers no longer normalize + each axis with `VariationAxis::normalize`, and the output equals `instance` at the + same normalized coordinates. + +Fixed: + +- Text whose first letter sits outside the blocks of the older script buckets shapes + with its script, as in HarfBuzz (#288, #292). It shaped as `Other` under `DFLT`. + Roboto "ỷ fi" lost the fi ligature ([1139, 4, 74, 77], where HarfBuzz gives + [1139, 4, 444]), the Windows 11 sans "Ựa" lost its kerning, Arabic Extended-A shaped + left to right with no joining in all 7 fonts tested (Tahoma U+08A0 U+0628 U+0644), and + a Mongolian Supplement birga lost the joining forms of the letters after it. Over + 11,043 such texts in 30 fonts, mismatches against HarfBuzz 14.5.0 fall from 1,258 to 8 + (Coptic, which has no bucket). +- Text with no script-bearing character shapes left to right, as HarfBuzz's invalid + script does (#288, #292). Leading Arabic marks before a tatweel were reversed: Arial + U+064F U+064B U+0640 gave [757, 753, 752], where HarfBuzz gives [753, 757, 752]. +- The `stch` stretch (U+070F SYRIAC ABBREVIATION MARK) counts symbols (General_Category + Sc, Sk, Sm and So) into the word it fills, as HarfBuzz does (#288, #292). A symbol + ended the word, so the stretch came out short. +- A feature list that names a tag more than once takes its last value, as HarfBuzz does: + `liga=0,liga=1` ligates and `kern=0,kern=1` kerns (#289, #295). Any zero entry used to + turn the feature off. +- `Face::glyph_points`, which `kerx` anchors read, gives a font without `vmtx` + HarfBuzz's vertical phantom points, (0, yMax) and (0, yMax - unitsPerEm), as the + outline and bounds methods do; they were (0, 0) (#289, #295). +- VARC records read as HarfBuzz reads them (#291, #293): ScaleY defaults to ScaleX, each + reserved flag bit skips one uint32var instead of ending the glyph, the axis-values + variation index is read without HAVE_AXES, an axis index past 4096 is ignored, an + out-of-range axis indices index names no axes, a 24-bit glyph id past 65535 draws + nothing, and the sparse region falloff follows HarfBuzz for zero peaks and invalid + triples. +- VARC component axis values plus their deltas round to F2DOT14 (halves up) before they + become the child's coords, as in HarfBuzz (#291, #293). +- VARC deltas apply at the default instance as HarfBuzz applies them: a variable font + holds a zero per axis there, so deltas from a region that constrains no axis apply, + and a font without `fvar` applies none (#291, #293, #296, #298). +- VARC conditions gate components, with all five ConditionList formats and a 64-level + nesting limit, and a `RESET_UNSPECIFIED_AXES` component starts from the font's coords + at any depth, through `Face::glyph_outline_at_coords` and `GlyphOutlines` (#291, #293, + #296, #298). Conditions were read and ignored, and the reset was ignored. +- A VARC component that names its own glyph draws that glyph's `glyf` or CFF outline, as + HarfBuzz does, instead of failing the glyph at the 64-level depth cap (#296, #298, + #300). Cycles of up to 32 glyphs stop where HarfBuzz's decycler stops them; a longer + cycle still fails at the 64-level cap. A glyph the coverage names past the glyph + records draws nothing, as in HarfBuzz. +- VARC component rotations and skews are composed as HarfBuzz's `to_transform` composes + them, with `sinf`, `cosf` and `tanf` precision (#296, #300). A rotation past about 90 + degrees was off: a box rotated 180 degrees about a center 500 units away landed + 42 units from HarfBuzz's, and HarfBuzz's `varc-6868` test font drew 2 units off. +- `morx` contextual subtables read their substitution table as the spec's unsized array + of offsets, so contextual substitutions apply; at the end of text they substitute only + after a mark was set, and then on the last glyph, and the mark starts on the first + glyph, as in HarfBuzz (#291, #293). +- `morx` ligatures form as in HarfBuzz (#291, #293): a component set twice (DontAdvance) + counts once, so the ligature or a neighbour is no longer deleted; the ligature stays + on the component stack for cascading ligatures; Store or Last stores; and deleted + components stay in the run, in the deleted-glyph class, until every chain has run. +- `morx` substitutions to glyph 1 apply; the lookup reader's out-of-bounds class read as + "no substitution" (#291, #293). +- `sigilbuzz-render`: progressive JPEGs decode (#291, #293). The AC scans stored each + coefficient at its natural position and the IDCT stage mapped it through the zig-zag + table a second time, so any progressive sbix JPEG with AC energy decoded to the wrong + picture. One-component scans walked the MCU-padded block grid instead of the + component's own blocks (T.81 A.2.2), which misread frames that end partway through an + MCU, and a one-component baseline frame that declares 2x2 sampling now reads its + blocks the same way. +- `sigilbuzz-render`: AC refinement scans (`Ah > 0`) decode, as T.81 G.1.2.3 and libjpeg + read them (#291, #293). libjpeg's default progression, which Pillow writes, uses them, + so no such file decoded; all 432 Pillow-written test files now decode to the same + pixels as their baseline twins. A DC refinement scan no longer needs a DC Huffman + table. +- `sigilbuzz-paint` 0.4.0: `PaintVar*`, `VarColorStop` and clip box deltas resolve at + the coords rounded to F2DOT14, as HarfBuzz does and as shaping and outlines already + did, so varied paints no longer drift up to half a unit from HarfBuzz's and from the + outlines they fill (#291, #293). +- `sigilbuzz-subset`: hinted CFF2 fonts subset (#290, #294). Blended stem hints were not + counted, so hint masks were misread: Source Serif 4 VF failed "Hello" and 553 of its + 1,463 single-glyph subsets with "CFF unknown charstring operator". All of them now + subset and draw as the source in sigilbuzz, fontTools and HarfBuzz. +- `sigilbuzz-subset`: a hint mask in a subroutine counts the stems its callers declared + (#290, #294). Hinted CFF1 fonts with masks in subroutines failed or kept the wrong + subroutines; across 779 CFF fonts, 2,965 of 21,650 test subsets that failed now + succeed, and 32 that succeeded with wrong outlines are now right. +- `sigilbuzz-subset`: `instance` sets `OS/2.usWeightClass`, `usWidthClass` and + `xAvgCharWidth` and `post.italicAngle` from the pinned `wght`, `wdth` and `slnt` and + the instance's advances, as HarfBuzz's instancer does, rounding halves up (#290, + #294). They kept the source's values. + +Known limitations: + +- A `VARC` table in the MultiItemVariationStore layout HarfBuzz adopted after 14.5.0 is + not read; it counts as absent, as in HarfBuzz 14.5.0 (#299). +- A fuzzed font whose GSUB grows a 30-character Devanagari text to 3,079 glyphs takes up + to 0.8 s to shape natively and about 24 s in a fuzz build (#302). It is not specific + to the new caches. +- The caches cut the setup of each call, not the work per glyph: long runs still take 2 + to 5 times as long per glyph as HarfBuzz (100-character runs: normalization, Hangul + composition, PairPos walks). A `Font` built for every run gets only the first-call + savings; keep one `Font` per font, size and instance to get the rest. +- `gvar` works out each tuple's scalar over every axis for each glyph, with no + per-shared-tuple cache, so fonts with very many axes are slow. No real font comes + close, but a crafted 2,000-axis font takes 4 to 7.6 s to instance partially, against + 3.7 s in HarfBuzz. +- `sigilbuzz-subset`: a partial CFF2 instance keeps the fractional values its store + projection gives, where HarfBuzz rounds each folded default and projected delta. + Source Serif 4 VF at `wght=650,opsz=keep` comes out at 2.59 MB against HarfBuzz's + 0.96 MB, and its default outlines differ from HarfBuzz's. + ## 0.23.1 (2026-10-04) The crates in this release: `sigilbuzz` 0.23.1, `sigilbuzz-render` 0.10.1, and diff --git a/Cargo.toml b/Cargo.toml index 67f22bd..c1873ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,11 +18,11 @@ repository = "https://github.com/Oneiriq/sigilbuzz" [workspace.dependencies] # Shared version pin. Companion crates use `sigilbuzz = { workspace = true }` # to pick up the shaper. -sigilbuzz = { path = ".", version = "0.23.1" } +sigilbuzz = { path = ".", version = "0.24.0" } [package] name = "sigilbuzz" -version = "0.23.1" +version = "0.24.0" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/README.md b/README.md index a60827f..26129eb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ same output for the same input every time. ## Status -The current release is 0.23.1. sigilbuzz is still pre-1.0, so a minor release can +The current release is 0.24.0. sigilbuzz is still pre-1.0, so a minor release can change the API. The names exported from the crate root are the ones I intend to keep stable. [docs/STABILITY.md](docs/STABILITY.md) lists them. @@ -61,7 +61,7 @@ Each crate has its own README with an example. ```toml [dependencies] -sigilbuzz = "0.23" +sigilbuzz = "0.24" ``` ```rust diff --git a/crates/sigilbuzz-capi/Cargo.toml b/crates/sigilbuzz-capi/Cargo.toml index 9a3e8f7..8adf8b5 100644 --- a/crates/sigilbuzz-capi/Cargo.toml +++ b/crates/sigilbuzz-capi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigilbuzz-capi" -version = "0.4.0" +version = "0.5.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -77,10 +77,10 @@ capi = [] sigilbuzz = { workspace = true } # Optional, enabled by the `subset` cargo feature. Provides the # subsetter sigilbuzz-capi's `hb_subset_*` shim wraps. -sigilbuzz-subset = { path = "../sigilbuzz-subset", version = "0.13.0", optional = true } +sigilbuzz-subset = { path = "../sigilbuzz-subset", version = "0.14.0", optional = true } # Optional, enabled by the `paint` cargo feature. Provides the # COLRv1 evaluator sigilbuzz-capi's `hb_paint_*` shim wraps. -sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.3.0", optional = true } +sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.4.0", optional = true } [dev-dependencies] # `cc` builds the C source files in `tests/c/` against the cdylib diff --git a/crates/sigilbuzz-cli/Cargo.toml b/crates/sigilbuzz-cli/Cargo.toml index 5807c3a..9caa2b0 100644 --- a/crates/sigilbuzz-cli/Cargo.toml +++ b/crates/sigilbuzz-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigilbuzz-cli" -version = "0.2.0" +version = "0.3.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -18,12 +18,12 @@ path = "src/main.rs" [dependencies] sigilbuzz = { workspace = true } -sigilbuzz-subset = { path = "../sigilbuzz-subset", version = "0.13.0" } -sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.3.0" } -sigilbuzz-gpu = { path = "../sigilbuzz-gpu", version = "0.2.0" } -sigilbuzz-svg = { path = "../sigilbuzz-svg", version = "0.3.0", features = ["color"] } -sigilbuzz-pdf = { path = "../sigilbuzz-pdf", version = "0.3.0" } -sigilbuzz-woff = { path = "../sigilbuzz-woff", version = "0.3.2" } +sigilbuzz-subset = { path = "../sigilbuzz-subset", version = "0.14.0" } +sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.4.0" } +sigilbuzz-gpu = { path = "../sigilbuzz-gpu", version = "0.3.0" } +sigilbuzz-svg = { path = "../sigilbuzz-svg", version = "0.4.0", features = ["color"] } +sigilbuzz-pdf = { path = "../sigilbuzz-pdf", version = "0.4.0" } +sigilbuzz-woff = { path = "../sigilbuzz-woff", version = "0.3.3" } clap = { version = "4", features = ["derive"] } [dev-dependencies] diff --git a/crates/sigilbuzz-gpu/Cargo.toml b/crates/sigilbuzz-gpu/Cargo.toml index b8dcbc3..6b3bc94 100644 --- a/crates/sigilbuzz-gpu/Cargo.toml +++ b/crates/sigilbuzz-gpu/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigilbuzz-gpu" -version = "0.2.0" +version = "0.3.0" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/crates/sigilbuzz-hyphen/Cargo.toml b/crates/sigilbuzz-hyphen/Cargo.toml index f523f59..162e5b2 100644 --- a/crates/sigilbuzz-hyphen/Cargo.toml +++ b/crates/sigilbuzz-hyphen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigilbuzz-hyphen" -version = "0.2.0" +version = "0.3.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -26,7 +26,7 @@ patterns-es = ["std"] text-layout-integration = ["dep:sigilbuzz-text-layout"] [dependencies] -sigilbuzz-text-layout = { path = "../sigilbuzz-text-layout", version = "0.2.0", optional = true } +sigilbuzz-text-layout = { path = "../sigilbuzz-text-layout", version = "0.3.0", optional = true } [lints] workspace = true diff --git a/crates/sigilbuzz-paint/Cargo.toml b/crates/sigilbuzz-paint/Cargo.toml index afe12cf..7d207e9 100644 --- a/crates/sigilbuzz-paint/Cargo.toml +++ b/crates/sigilbuzz-paint/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigilbuzz-paint" -version = "0.3.0" +version = "0.4.0" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/crates/sigilbuzz-pdf/Cargo.toml b/crates/sigilbuzz-pdf/Cargo.toml index 3e05753..43a6178 100644 --- a/crates/sigilbuzz-pdf/Cargo.toml +++ b/crates/sigilbuzz-pdf/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigilbuzz-pdf" -version = "0.3.0" +version = "0.4.0" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/crates/sigilbuzz-render/Cargo.toml b/crates/sigilbuzz-render/Cargo.toml index 6714d61..09b3f36 100644 --- a/crates/sigilbuzz-render/Cargo.toml +++ b/crates/sigilbuzz-render/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigilbuzz-render" -version = "0.10.1" +version = "0.11.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -18,7 +18,7 @@ std = [] [dependencies] sigilbuzz = { workspace = true } -sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.3.0" } +sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.4.0" } # `miniz_oxide` provides the zlib INFLATE primitive used by the # embedded PNG decoder for CBDT / sbix bitmap tables. Same dep # already pulled in by `sigilbuzz-woff` for WOFF1 zlib (see diff --git a/crates/sigilbuzz-subset/Cargo.toml b/crates/sigilbuzz-subset/Cargo.toml index 53995c5..2793e07 100644 --- a/crates/sigilbuzz-subset/Cargo.toml +++ b/crates/sigilbuzz-subset/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigilbuzz-subset" -version = "0.13.1" +version = "0.14.0" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/crates/sigilbuzz-svg/Cargo.toml b/crates/sigilbuzz-svg/Cargo.toml index de5739b..836c6e3 100644 --- a/crates/sigilbuzz-svg/Cargo.toml +++ b/crates/sigilbuzz-svg/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigilbuzz-svg" -version = "0.3.0" +version = "0.4.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -21,7 +21,7 @@ color = ["dep:sigilbuzz-paint"] [dependencies] sigilbuzz = { workspace = true } -sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.3.0", optional = true } +sigilbuzz-paint = { path = "../sigilbuzz-paint", version = "0.4.0", optional = true } [dev-dependencies] diff --git a/crates/sigilbuzz-text-layout/Cargo.toml b/crates/sigilbuzz-text-layout/Cargo.toml index 872ae5d..3ca92bf 100644 --- a/crates/sigilbuzz-text-layout/Cargo.toml +++ b/crates/sigilbuzz-text-layout/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigilbuzz-text-layout" -version = "0.2.0" +version = "0.3.0" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/crates/sigilbuzz-woff/Cargo.toml b/crates/sigilbuzz-woff/Cargo.toml index 5777db9..0e4ae45 100644 --- a/crates/sigilbuzz-woff/Cargo.toml +++ b/crates/sigilbuzz-woff/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sigilbuzz-woff" -version = "0.3.2" +version = "0.3.3" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 1d93a10..572d0b9 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -1,10 +1,273 @@ # Performance -Criterion numbers from the 0.6.0 release, measured on the maintainer's macOS arm64 -workstation. If you change a hot path, re-run the benchmarks and update this file in -the same change, so the history stays in one place. +Numbers for 0.24.0. The per-call shaping tables come from the measurements in the +0.24.0 pull requests (#295 and #301), which compare sigilbuzz with 0.23.0 and with +HarfBuzz 14.5.0. The Criterion numbers further down were rerun for this release. If you +change a hot path, measure again and update this file in the same change, so the history +stays in one place. Numbers from earlier releases are in this file's git history. -## Running the benchmarks +## How 0.24.0 shapes faster + +A `Font` keeps what HarfBuzz keeps per face and font, from its second shaping call on: + +- a glyph digest per lookup and subtable, so a lookup that cannot apply to any glyph of + the run is skipped before it is parsed, and subtables are parsed only when a glyph + their digest admits reaches them; +- the language system each script, language and FeatureVariations record resolves to; +- the merged lookups of each shaping stage; +- per-glyph vertical origins and varied advances (`HVAR`, `VVAR` or phantom points). + +Clones made after a font's first call share these caches. A font's first call builds +none of them, so code that builds a `Font` for every run gets only part of the gain. +Keep one `Font` per font, size and instance. The Indic, Khmer, Myanmar and USE syllable +grammars are compiled once per process. Output never depends on the caches. + +## Per-call shaping + +### Method + +- One Windows 11 laptop (Intel i9-11900H), shared with other jobs at about 25% + background load, so differences under about 4% are noise. +- Release builds with `lto = "thin"` and `codegen-units = 1`, one sigilbuzz version per + binary, as a user links it. +- Each row shapes 20 texts of 1, 8 or 100 characters (Hangul syllables, Latin letters + and spaces, Arabic letters, Devanagari words) with no features, either after + `guess_segment_properties` (LTR: horizontal, right to left for Arabic) or top to + bottom (TTB). +- A run reports the median thread-cycle time of 11 batches of about 10 ms. The versions + of a row run in alternation, and a cell is the median over 5 runs (3 for first + calls). Times are microseconds per call. +- The 0.23.0 and 0.24.0 columns, and the hostile-table numbers below, were measured + for #301 on its branch. Its last two commits came after: one changes only first + calls of lookups with more than 1,024 subtables, which were measured again and were + within noise, and one caps how much room a lookup's parsed subtables reserve, which + was not timed again. +- HarfBuzz is 14.5.0 through uharfbuzz 0.56.2, measured for #295 on the same laptop: + the median of 15 wall-clock batches from Python, best of 2. Each HarfBuzz number + includes about 0.5 to 1 us of Python per call, so HarfBuzz's own time is that much + lower. + +### Repeated calls on one `Font` + +This is the case the caches are for, and what an application that keeps its fonts +sees. + +| Font | Axes | Dir | Chars | 0.23.0 | 0.24.0 | HarfBuzz 14.5.0 | +|---|---|---|---|---|---|---| +| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 1 | 56.51 | 3.38 | 1.13 | +| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 8 | 70.60 | 7.73 | 1.82 | +| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 100 | 228.42 | 42.49 | 20.51 | +| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 1 | 59.31 | 3.42 | 1.85 | +| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 8 | 66.63 | 6.38 | 3.08 | +| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 100 | 222.00 | 36.70 | 25.30 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 1 | 58.89 | 3.47 | 1.90 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 8 | 71.06 | 8.86 | 2.29 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 100 | 246.20 | 47.66 | 19.61 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 1 | 57.70 | 3.61 | 1.63 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 8 | 71.24 | 7.47 | 2.61 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 100 | 266.18 | 54.90 | 30.32 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 1 | 57.47 | 3.21 | 1.27 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 8 | 92.63 | 8.12 | 1.85 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 100 | 220.00 | 44.09 | 16.01 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 1 | 58.17 | 3.30 | 1.93 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 8 | 66.09 | 6.32 | 2.44 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 100 | 231.34 | 37.37 | 27.33 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 1 | 57.77 | 3.46 | 1.24 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 8 | 66.77 | 7.77 | 1.88 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 100 | 253.46 | 49.81 | 22.09 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 1 | 56.65 | 3.67 | 2.08 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 8 | 85.79 | 7.68 | 3.62 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 100 | 315.74 | 50.65 | 62.87 | +| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 1 | 13.19 | 2.97 | 1.63 | +| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 8 | 16.96 | 4.59 | 1.75 | +| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 100 | 90.39 | 22.29 | 15.19 | +| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 1 | 7.07 | 2.99 | 1.45 | +| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 8 | 10.12 | 4.39 | 7.87 | +| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 100 | 66.91 | 20.13 | 227.45 | +| Source Code Pro (CFF, no VORG) | default | LTR | 1 | 1.02 | 1.11 | 1.08 | +| Source Code Pro (CFF, no VORG) | default | LTR | 8 | 1.61 | 1.71 | 2.23 | +| Source Code Pro (CFF, no VORG) | default | LTR | 100 | 7.05 | 8.06 | 5.15 | +| Source Code Pro (CFF, no VORG) | default | TTB | 1 | 1.12 | 1.76 | 1.78 | +| Source Code Pro (CFF, no VORG) | default | TTB | 8 | 1.87 | 1.88 | 1.41 | +| Source Code Pro (CFF, no VORG) | default | TTB | 100 | 9.54 | 8.57 | 5.22 | +| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 1 | 9.76 | 3.64 | 1.67 | +| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 8 | 14.24 | 6.90 | 2.41 | +| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 100 | 72.64 | 43.68 | 10.85 | +| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 1 | 7.14 | 2.81 | 1.47 | +| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 8 | 10.58 | 4.68 | 1.65 | +| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 100 | 49.00 | 22.83 | 4.78 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 1 | 9.84 | 3.95 | 1.32 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 8 | 18.96 | 8.15 | 2.21 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 100 | 118.53 | 62.23 | 13.48 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 1 | 7.64 | 3.11 | 1.51 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 8 | 13.03 | 4.71 | 1.77 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 100 | 77.40 | 23.66 | 6.33 | +| Segoe UI | default | LTR | 1 | 7.08 | 2.29 | 1.47 | +| Segoe UI | default | LTR | 8 | 10.12 | 3.78 | 2.32 | +| Segoe UI | default | LTR | 100 | 53.78 | 21.86 | 5.63 | +| Segoe UI | default | TTB | 1 | 6.33 | 2.15 | 2.12 | +| Segoe UI | default | TTB | 8 | 9.75 | 3.24 | 1.86 | +| Segoe UI | default | TTB | 100 | 50.46 | 16.35 | 4.80 | +| Roboto | default | LTR | 1 | 5.41 | 2.68 | 1.29 | +| Roboto | default | LTR | 8 | 8.17 | 5.72 | 2.16 | +| Roboto | default | LTR | 100 | 42.36 | 36.23 | 9.67 | +| Roboto | default | TTB | 1 | 4.19 | 2.14 | 1.63 | +| Roboto | default | TTB | 8 | 6.08 | 4.01 | 1.58 | +| Roboto | default | TTB | 100 | 28.50 | 20.18 | 3.71 | +| Amiri (Arabic) | default | LTR | 1 | 58.51 | 9.63 | 1.63 | +| Amiri (Arabic) | default | LTR | 8 | 90.63 | 30.44 | 8.14 | +| Amiri (Arabic) | default | LTR | 100 | 413.22 | 208.64 | 78.07 | +| Amiri (Arabic) | default | TTB | 1 | 46.15 | 4.17 | 1.60 | +| Amiri (Arabic) | default | TTB | 8 | 56.94 | 8.53 | 4.09 | +| Amiri (Arabic) | default | TTB | 100 | 216.28 | 49.65 | 18.13 | +| Noto Sans Devanagari | default | LTR | 1 | 370.87 | 10.12 | 1.98 | +| Noto Sans Devanagari | default | LTR | 8 | 438.20 | 29.12 | 5.38 | +| Noto Sans Devanagari | default | LTR | 100 | 806.56 | 204.08 | 52.49 | +| Noto Sans Devanagari | default | TTB | 1 | 360.25 | 9.98 | 2.59 | +| Noto Sans Devanagari | default | TTB | 8 | 422.78 | 37.47 | 6.88 | +| Noto Sans Devanagari | default | TTB | 100 | 799.29 | 206.75 | 55.33 | + +Long runs still take 2 to 5 times as long per glyph as in HarfBuzz: normalization, +Hangul composition and PairPos walks did not change in 0.24.0. + +### A fresh `Face` and `Font` for every call + +The first-call path, for code that builds a `Font` per run. A font's first +top-to-bottom call is slower than in 0.23.0 for fonts without `VORG` (Hahmlet, Source +Serif 4 VF, Source Code Pro), because their vertical origins come from glyph extents +since 0.23.1, and a first call works those out for every new glyph. + +
Full table + +| Font | Axes | Dir | Chars | 0.23.0 | 0.24.0 | +|---|---|---|---|---|---| +| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 1 | 56.45 | 12.74 | +| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 8 | 63.65 | 25.76 | +| Noto Sans KR VF otf (CFF2, VORG) | default | LTR | 100 | 218.88 | 163.43 | +| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 1 | 56.43 | 12.54 | +| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 8 | 65.47 | 23.08 | +| Noto Sans KR VF otf (CFF2, VORG) | default | TTB | 100 | 216.12 | 170.51 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 1 | 59.94 | 13.55 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 8 | 67.88 | 26.94 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | LTR | 100 | 244.63 | 175.47 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 1 | 53.85 | 13.17 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 8 | 68.31 | 25.68 | +| Noto Sans KR VF otf (CFF2, VORG) | wght=700 | TTB | 100 | 240.42 | 190.12 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 1 | 56.16 | 13.46 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 8 | 68.36 | 24.07 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | LTR | 100 | 211.36 | 159.16 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 1 | 54.41 | 12.87 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 8 | 65.31 | 23.67 | +| Noto Sans KR VF ttf (glyf, vmtx) | default | TTB | 100 | 212.30 | 172.75 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 1 | 54.40 | 13.41 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 8 | 68.45 | 26.02 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | LTR | 100 | 244.49 | 184.43 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 1 | 54.41 | 13.89 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 8 | 81.58 | 30.00 | +| Noto Sans KR VF ttf (glyf, vmtx) | wght=700 | TTB | 100 | 347.54 | 254.19 | +| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 1 | 13.33 | 9.58 | +| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 8 | 17.70 | 11.82 | +| Hahmlet (glyf, no vmtx) | wght=700 | LTR | 100 | 91.97 | 74.23 | +| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 1 | 7.09 | 13.33 | +| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 8 | 10.53 | 41.80 | +| Hahmlet (glyf, no vmtx) | wght=700 | TTB | 100 | 69.16 | 510.91 | +| Source Code Pro (CFF, no VORG) | default | LTR | 1 | 1.12 | 1.20 | +| Source Code Pro (CFF, no VORG) | default | LTR | 8 | 1.70 | 1.83 | +| Source Code Pro (CFF, no VORG) | default | LTR | 100 | 7.21 | 8.01 | +| Source Code Pro (CFF, no VORG) | default | TTB | 1 | 1.23 | 1.97 | +| Source Code Pro (CFF, no VORG) | default | TTB | 8 | 1.94 | 4.35 | +| Source Code Pro (CFF, no VORG) | default | TTB | 100 | 9.67 | 24.41 | +| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 1 | 9.07 | 6.47 | +| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 8 | 13.92 | 11.60 | +| Source Serif 4 VF (CFF2, no VORG) | default | LTR | 100 | 69.69 | 54.30 | +| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 1 | 7.69 | 9.61 | +| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 8 | 11.31 | 27.84 | +| Source Serif 4 VF (CFF2, no VORG) | default | TTB | 100 | 50.00 | 160.56 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 1 | 9.56 | 7.03 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 8 | 19.12 | 16.12 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | LTR | 100 | 124.17 | 104.79 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 1 | 8.78 | 10.55 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 8 | 14.39 | 34.31 | +| Source Serif 4 VF (CFF2, no VORG) | wght=700 | TTB | 100 | 81.98 | 189.34 | +| Segoe UI | default | LTR | 1 | 7.08 | 6.35 | +| Segoe UI | default | LTR | 8 | 10.76 | 9.35 | +| Segoe UI | default | LTR | 100 | 58.14 | 44.59 | +| Segoe UI | default | TTB | 1 | 6.58 | 5.55 | +| Segoe UI | default | TTB | 8 | 9.95 | 8.15 | +| Segoe UI | default | TTB | 100 | 52.04 | 36.11 | +| Roboto | default | LTR | 1 | 5.84 | 5.62 | +| Roboto | default | LTR | 8 | 8.24 | 8.89 | +| Roboto | default | LTR | 100 | 42.39 | 47.02 | +| Roboto | default | TTB | 1 | 4.58 | 3.90 | +| Roboto | default | TTB | 8 | 7.23 | 6.03 | +| Roboto | default | TTB | 100 | 29.56 | 31.59 | +| Amiri (Arabic) | default | LTR | 1 | 63.74 | 17.53 | +| Amiri (Arabic) | default | LTR | 8 | 88.06 | 50.56 | +| Amiri (Arabic) | default | LTR | 100 | 455.94 | 388.00 | +| Amiri (Arabic) | default | TTB | 1 | 46.51 | 11.32 | +| Amiri (Arabic) | default | TTB | 8 | 58.75 | 22.69 | +| Amiri (Arabic) | default | TTB | 100 | 213.42 | 164.33 | +| Noto Sans Devanagari | default | LTR | 1 | 370.78 | 45.64 | +| Noto Sans Devanagari | default | LTR | 8 | 464.15 | 70.46 | +| Noto Sans Devanagari | default | LTR | 100 | 847.40 | 499.24 | +| Noto Sans Devanagari | default | TTB | 1 | 380.26 | 40.97 | +| Noto Sans Devanagari | default | TTB | 8 | 421.72 | 68.84 | +| Noto Sans Devanagari | default | TTB | 100 | 776.64 | 498.88 | + +
+ +### Hostile layout tables + +Open Sans with its GSUB or GPOS replaced by a crafted table, measured for #301. Time per +call on one `Font` (a first call in parentheses): the median of 9 alternating runs for +the rows in milliseconds, and the range of four single calls for the rows in seconds. +Output is the same in both versions. + +| Table | Text | 0.23.1 | 0.24.0 | +|---|---|---|---| +| GSUB, 32,000 offsets to one ChainContextFormat2 (64 KB) | aaaa | 5.19 ms (4.93) | 5.49 ms (5.47) | +| GSUB, 8,000 of them (16 KB) | aaaa | 1.31 ms (1.36) | 1.37 ms (1.44) | +| GSUB, 32,000 offsets to one ChainContextFormat3 (64 KB) | a | 6.80 ms (6.56) | 6.10 ms (6.49) | +| GSUB, 32,000 offsets to one ChainContextFormat1 (64 KB) | aaaa | 3.99 ms (4.02) | 4.32 ms (4.37) | +| GPOS, 32,000 offsets to one ChainContextPos format 2 (64 KB) | aaaa | 4.85 ms (5.06) | 5.30 ms (5.61) | +| GSUB, 500 indices of one lookup of 32,000 format 3 subtables (66 KB) | a | 5.6 to 7.0 s | 4.5 to 5.9 s | +| GPOS, the same with ChainContextPos (66 KB) | a | 6.1 to 6.7 s | 4.1 to 4.7 s | +| GSUB, 8,000 overlapping lookups of 8,000 subtables (96 KB) | a | 1.6 to 2.0 s | 2.2 to 2.5 s | + +Heap over three calls on one `Font`, measured with a counting allocator: the peak +during the calls, and what the `Font` keeps after them. + +| Table | 0.23.1 peak / kept | 0.24.0 peak / kept | +|---|---|---| +| GSUB, 32,000 x ChainContextFormat2 | 5.4 MB / 0 | 5.5 MB / 2 KB | +| GPOS, 32,000 x format 2 | 5.6 MB / 0 | 5.8 MB / 3 KB | +| GSUB, 500 indices x 32,000 format 3 | 7.3 MB / 0 | 7.5 MB / 15 KB | +| GPOS, 500 indices x 32,000 format 3 | 7.6 MB / 0 | 7.7 MB / 15 KB | +| GSUB, 8,000 overlapping lookups | 1.4 MB / 0 | 7.8 MB / 6.3 MB | +| GSUB, 16 lookups x 2,000 distinct digests | 0.3 MB / 0 | 0.7 MB / 0.7 MB | + +What a `Font` keeps per GSUB or GPOS table is one slot pointer per lookup, a 40-byte +header per lookup applied, and 24 bytes per kept digest, for at most twice the table's +length plus 64 Ki digests. On top of that come at most 16 resolved language systems and +64 stage plans per table, and three per-glyph caches of at most 4,096 four-byte entries +each. The overlapping-lookups table is the one case only that budget bounds: 6.3 MB +kept for a 96 KB table. + +### Outlines + +`Face::glyph_outlines` against `Face::glyph_outline_at_coords`, per glyph over 100 +consecutive glyphs, measured for #295: + +| Font | `glyph_outline_at_coords` | `GlyphOutlines` | +|---|---|---| +| Source Serif 4 VF (CFF2), default | 5.12 us | 3.74 us | +| Source Serif 4 VF (CFF2), wght 700 | 5.29 us | 3.68 us | +| Noto Sans KR VF otf (CFF2) | 2.98 us | 2.09 us | +| Source Code Pro (CFF) | 0.75 us | 0.44 us | + +## Criterion benchmarks + +### Running them ```sh # Every benchmark in the workspace (Latin, Arabic, Devanagari, Khmer, Hebrew, plus @@ -20,83 +283,59 @@ cargo bench -p sigilbuzz-paint --bench evaluate cargo bench --bench shape_latin -- --quick ``` -Each shaping benchmark runs the same 200-codepoint text through sigilbuzz and -rustybuzz back to back. Absolute times depend on the machine. The ratio against -rustybuzz is the number to watch, because it stays stable across machines. +Each shaping benchmark shapes the same 200-codepoint text through sigilbuzz and +rustybuzz back to back. sigilbuzz shapes on one `Font`, so its caches are warm after +the first iteration. Absolute times depend on the machine. The ratio against rustybuzz +is the number to watch, because it moves less across machines. + +### Results for 0.24.0 -## Shaping: sigilbuzz vs rustybuzz +Measured on 2026-10-04 at the 0.24.0 release commit with `cargo bench --workspace +--bench shape_latin --bench shape_arabic --bench shape_devanagari --bench shape_khmer +--bench shape_hebrew --bench encode --bench evaluate`, Criterion's default settings +(3 s warm-up, 100 samples), on the laptop above. Each time is the middle of Criterion's +estimate. Other jobs ran on the laptop at the same time (12% to 25% CPU load at the +start and end of the run). A first run under full load took up to twice as long, and +its ratios differed by up to a factor of two (Khmer 5.5x instead of 2.45x), so read the +ratios as approximate and rerun on a quiet machine before comparing releases. The +0.6.0 numbers in this file's history came from a macOS arm64 workstation, so their +absolute times do not compare with these. | Benchmark | sigilbuzz | rustybuzz | Ratio | Notes | |---|---|---|---|---| -| shape_latin | 11.39 µs | 15.03 µs | 0.76x | Open Sans, ASCII pangrams with liga and kern. | -| shape_arabic | 290 µs | 108 µs | 2.70x | Amiri, heavy rlig use with IgnoreMarks. | -| shape_devanagari | 419 µs | 85.8 µs | 4.89x | Noto Sans Devanagari, reph and conjuncts. | -| shape_khmer | 91.6 µs | 49.0 µs | 1.87x | Noto Sans Khmer, USE state machine. | -| shape_hebrew | 10.99 µs | 17.28 µs | 0.64x | Noto Sans Hebrew, GPOS mark-to-base and mark-to-mark. | +| shape_latin | 50.9 µs | 37.5 µs | 1.36x | Open Sans, ASCII pangrams with liga and kern. | +| shape_arabic | 455 µs | 263 µs | 1.73x | Amiri, heavy rlig use with IgnoreMarks. | +| shape_devanagari | 772 µs | 250 µs | 3.09x | Noto Sans Devanagari, reph and conjuncts. | +| shape_khmer | 342 µs | 140 µs | 2.45x | Noto Sans Khmer, Khmer shaper. | +| shape_hebrew | 62.1 µs | 41.4 µs | 1.50x | Noto Sans Hebrew, GPOS mark-to-base and mark-to-mark. | A ratio below 1 means sigilbuzz is faster. -### What changed in 0.6.0 (#74, #75, #76) +GPU encoder (`sigilbuzz-gpu`). rustybuzz has no Slug-style outline encoder, so these +numbers are only compared with earlier sigilbuzz-gpu runs. -In 0.5.0, Devanagari took 173x as long as rustybuzz, Arabic 32x, and Khmer 17x. Every -complex-script benchmark was an outlier for algorithmic reasons. Three fixes in -`src/shape.rs` and `src/tables/gsub/*.rs` brought each of them under 5x and left Latin -and Hebrew faster than rustybuzz: - -1. Build the glyph ID snapshot once. The context, chained-context, and reverse-chain - matchers used to rebuild a `Vec` of every glyph ID at every cursor step. Now - `apply_gsub_lookup` builds it once (`GlyphIds`) and updates it as it goes. Single and - Alternate substitutions touch one slot. Ligature and Multiple resync. Devanagari - -17%. - -2. Parse each subtable once per lookup. Parsing a format 3 context subtable allocates - four `Vec`s, and the cursor walk used to pay that at every position. The parsed form - (`ParsedGsubSubtable`) is now built once when `apply_gsub_lookup` starts and reused - for the whole walk. Devanagari -94%, Arabic -86%, Khmer -75%. - -3. Skip lookups that can't apply. Each lookup first checks whether any glyph in the - run is in any subtable's coverage. During the walk, only positions whose glyph is in - that set get full subtable dispatch. The rest skip after one `cov.contains` check - per subtable. This mirrors HarfBuzz's skip-iterator check. Devanagari -23%, Arabic - -37%, Khmer -47%. - -The chained-context matchers also check `input[0]` before walking the backtrack -sequence, as HarfBuzz does. The per-syllable `compute_half_mask` dry run now caches -`feature_would_substitute` results, so repeated `(halant, c2)` pairs don't walk GSUB -again. - -Latin and Hebrew got faster as a side effect. The cursor walk savings also apply to -the default `liga`, `calt`, and `clig` features and to `mark` and `mkmk` in GPOS. - -## GPU encoder (`sigilbuzz-gpu`) - -rustybuzz has no Slug-style outline encoder, so these numbers are only compared with -earlier sigilbuzz-gpu runs. - -| Glyph | Mean | Notes | +| Glyph | Time | Notes | |---|---|---| -| opensans_A | 852 ns | Simple glyph, mostly straight lines. | -| opensans_g | 1.78 µs | Descender and a closed two-story bowl. | -| opensans_O | 979 ns | An oval, all four curved quadrants. | -| amiri_alef | 896 ns | Long vertical stroke with a thin tail. | - -## COLRv1 evaluator (`sigilbuzz-paint`) +| opensans_A | 3.57 µs | Simple glyph, mostly straight lines. | +| opensans_g | 6.09 µs | Descender and a closed two-story bowl. | +| opensans_O | 3.39 µs | An oval, all four curved quadrants. | +| amiri_alef | 3.09 µs | Long vertical stroke with a thin tail. | -rustybuzz does not evaluate COLRv1, so these numbers are only compared with earlier -sigilbuzz-paint runs. +COLRv1 evaluator (`sigilbuzz-paint`). rustybuzz does not evaluate COLRv1, so these +numbers are only compared with earlier sigilbuzz-paint runs. -| Fixture | Mean | +| Fixture | Time | |---|---| -| solid | 58 ns | -| linear_gradient | 92 ns | -| translate_scale | 92 ns | -| composite | 86 ns | -| radial_gradient | 86 ns | -| sweep_gradient | 97 ns | -| var_solid_at_coords | 69 ns | +| solid | 488 ns | +| linear_gradient | 696 ns | +| translate_scale | 548 ns | +| composite | 692 ns | +| radial_gradient | 647 ns | +| sweep_gradient | 656 ns | +| var_solid_at_coords | 410 ns | -## Reading the Criterion output +### Reading the Criterion output Each shaping benchmark defines two entries side by side, `shape_