Skip to content

Fix the known JPEG, COLRv1, VARC and morx bugs - #293

Merged
albedosehen merged 13 commits into
mainfrom
fix/known-bugs-0.24
Oct 4, 2026
Merged

albedosehen merged 13 commits into
mainfrom
fix/known-bugs-0.24

Conversation

@albedosehen

@albedosehen albedosehen commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Closes #291

Fixes the known output-changing bugs outside the shaping path (audit items A15, A2, B9, A16, A17), plus the VARC and morx differences that comparing with HarfBuzz 14.5.0 turned up while verifying them. One commit per item, plus three commits for the review of 10724be (see Review fixes).

Commits

Commit Item What changed
Decode progressive JPEG AC coefficients at their zig-zag index A15 AC values stored at k, not ZIGZAG[k]; one-component scans walk the component's own block grid (T.81 A.2.2), progressive and baseline
Decode progressive JPEG AC refinement scans A2 T.81 G.1.2.3 / libjpeg decode_mcu_AC_refine; a 1024-visits-per-block budget for a frame's scans; DC refinement needs no DC table
Round COLRv1 paint and VARC axis coords to F2DOT14 B9 sigilbuzz-paint deltas read F2DOT14 coords; VARC axis value plus deltas rounds (floor(x + 0.5)) before it becomes a coord
Read VARC component records and delta sets as HarfBuzz does found while verifying A16 MultiItemVariationStore delta sets are region-major; ScaleY defaults to ScaleX; reserved flag bits skip one uint32var each; the axis-values var index is read without HAVE_AXES; HarfBuzz's 4096-axis limit, out-of-range axis indices index, 24-bit gids, region falloff edge cases
Gate VARC components on conditions and honor RESET_UNSPECIFIED_AXES A16 ConditionList formats 1 to 5; reset starts the child from the font's coords; new Varc::composite_with_font_coords
Read morx contextual substitution tables as an unsized offset array A17 (1) plus HarfBuzz's end-of-text rule and initial mark
Form morx ligatures as HarfBuzz does, a component set twice once A17 (2) HarfBuzz's LigatureSubtable: no double push, ligature stays on the stack, Store or Last stores, deleted glyphs kept until morx ends
Apply morx substitutions to glyph 1 found while verifying A17 the lookup reader's out-of-bounds class (1) was read as "no substitution"
Check VARC and morx against HarfBuzz on crafted fixtures tests three byte-built fonts plus HarfBuzz 14.5.0 expected files
Reject a second frame in a JPEG stream review 2 a second SOF is an error, so the scan budget bounds the stream
Back a JPEG frame by its scan data and cap it at 2^24 pixels review 3 only SOS data backs a frame's blocks; a pixel cap
Cap JPEG frames at 2^22 pixels review 3 the cap lowered to 2048 x 2048 after fuzzing timed out at 4096 x 4096
Bound VARC condition and delta work per glyph review 1 condition results and region scalars kept per call; a 2^20-unit delta work budget

Evidence, before and after

A15 (zig-zag) and the non-interleaved block grid. A test-only encoder writes the same quantized coefficients as one baseline scan and as progressive scripts. Before, 4 of the new tests failed (a single block with AC energy at zig-zag 1, 2 and 5; spectral selection over grayscale, 4:4:4, 4:2:2, 4:2:0, 4:4:0 at sizes ending partway through a block or MCU; EOB runs and ZRLs; a luma-only DC scan of a 20x12 4:2:0 frame), and 2 more failed against the old baseline decoder (grayscale declaring 2x2 sampling). After, every progressive decode equals the baseline decode byte for byte.

A2 (AC refinement), against Pillow 12.3.0 (libjpeg-turbo). 432 progressive JPEGs written by Pillow (progressive=True, libjpeg's default script with DC and AC refinement): nine sizes from 1x1 to 137x160, four pictures, grayscale and 4:4:4 / 4:2:2 / 4:2:0, quality 50 / 90 / 100.

before after
decoded 0 of 432 (362 "AC refinement scans not supported", 68 "Huffman decode error", 2 "AC run overflow in band") 432 of 432
equal to the decode of the baseline twin (same quantized coefficients) n/a 432 of 432, byte for byte
vs Pillow's decode, grayscale n/a max 1 level, PSNR >= 61.4 dB
vs Pillow's decode, 4:4:4 n/a max 4 levels, PSNR >= 49.9 dB (integer vs float IDCT, color conversion)
vs Pillow's decode, 4:2:2 / 4:2:0 n/a max 95 levels, PSNR >= 22.9 dB, identical to the baseline twins' numbers: nearest-neighbor chroma upsampling vs libjpeg's fancy upsampling (unchanged here)

Hand-built streams also cover an EOB run of 32,767 blocks in a two-block frame, a new value past the band, a refinement scan cut short, repeated scans running out the work budget, and a DC refinement scan whose table slot is empty. Four Pillow files and their baseline twins are now fixtures (tests/tools/build_progressive_jpeg_fixtures.py).

B9 (F2DOT14), against HarfBuzz's paint API (uharfbuzz 0.56.2, HarfBuzz 14.5.0). Google's COLRv1 variable test font (test_glyphs-glyf_colr_1_variable.ttf, 44 axes, 200 glyphs, every PaintVar* format), walked by sigilbuzz-paint and by hb_font_paint_glyph at three random off-grid locations, comparing every color, gradient, stop and clip-glyph callback with the transform in effect:

before after
glyphs with a value more than 0.001 off 37, 35, 34 of 200 (worst 0.54 units) 0 of 200 at each (worst 0.00022)

Nabla matches HarfBuzz at five EDPT / EHLT settings before and after: its COLR has no variation store and its outlines were already rounded. VARC axis rounding: axis_values_plus_deltas_round_to_f2dot14 fails before (0.5000305 vs 0.50006104) and passes after.

VARC (record parity and A16), against HarfBuzz's outlines. varc_parity.ttf: three axes, 14 VARC glyphs over two gvar-varied glyphs, every component field; outlines compared within 0.01 units.

before after decode parity after A16
glyphs matching per location (6 locations) 6 to 8 of 14 (46 mismatches) 12 of 14 13 of 14 through Face, 14 of 14 through composite_with_font_coords (8 locations)

A17 (morx), against HarfBuzz's AAT shaper (glyph ids and clusters).

font before after
contextual (mark, current, end-of-text, unset mark) 2 of 11 strings 11 of 11
ligature (cascading, a component set twice, Store without Last, action at end of text, underflow) 5 of 14 strings ("lx" came out as "l") 14 of 14
type 4 mapping b to glyph 1 "bx" kept b matches

Review fixes

Four follow-up commits from the independent review of head 10724be, and one note.

Commit Review item What changed
Reject a second frame in a JPEG stream 2 a second SOF is BadJpeg("second SOF"), as libjpeg's JERR_SOF_DUPLICATE, so the 1024-visits-per-block scan budget bounds the whole stream; docs say so
Back a JPEG frame by its scan data and cap it at 2^24 pixels 3 the blocks-per-byte check counts only entropy-coded bytes inside SOS segments, not COM/APPn padding; added a 4096 x 4096 pixel cap
Cap JPEG frames at 2^22 pixels 3 the images fuzz target (which also re-encodes each picture as PNG) timed out after 15 s on a 33 KB 4096 x 4096 stream under its sanitizer build, so the cap is now 2048 x 2048
Bound VARC condition and delta work per glyph 1 per call: condition results kept by table offset, region scalars kept by subtable, and a 2^20-unit budget charged per region index and per delta value walked
Input (reconstructed from the review's descriptions) head 10724be now
182 KB VARC font: one gated component, 20 And levels whose two offsets name the same child (2^20 paths), Value condition over a subtable naming one region 60,000 times; Face::glyph_outline_at_coords 22.5 s, and the component dropped when the visit budget ran out 0.9 to 1.6 ms, component shown (HarfBuzz shows it too; checked on a 6-level version, since HarfBuzz takes 516 s on the full one per the review)
Same with 6 levels (2^6 paths) 30 ms 0.9 ms
Progressive JPEG, frames back to back each with 16 full-band refinement scans, 4 KB / 16 KB / 64 KB 0.3 s / 6.4 s / not run (review: 1.3 s / 32.4 s / over 240 s) rejected in 0.0 / 0.0 / 0.1 ms
Two valid progressive frames in one stream (4.6 KB) decoded the second, 75 ms second SOF, 36 ms
574 KB stream declaring 16384 x 16384, padded with COM segments decoded, 4.0 s, peak 1,797 MB frame larger than entropy data, 0.0 ms, peak 1 MB
33 KB stream: 4096 x 4096 grayscale, one bit of DC data per block, 16 full-band refinement scans 0.77 s, 113 MB frame larger than pixel limit, 0.0 ms
64 KB stream of the same shape, 5768 x 5768 1.4 s, 224 MB frame larger than pixel limit, 0.1 ms
Worst stream that passes both JPEG checks: 8 KB, 2048 x 2048, same shape 0.11 s, 29 MB 0.10 s, 29 MB (now a bound for any stream; the images fuzz body takes 0.25 s on it with debug assertions)

Why 2^22 pixels: JPEG data in a font is an sbix glyph image drawn for one strike's ppem, and a 2048-pixel strike would be four times the area of a 1024 ppem one, past any strike fonts ship. A decode holds at most 13 bytes per pixel at once (a progressive 4:4:4 frame keeps 6 bytes of coefficients, 3 of sample planes and 4 of RGBA output), so one decode stays near 55 MB and well under a second. The first version of this fix capped at 2^24; fuzzing found that a 33 KB stream at that cap still costs 0.8 s and 113 MB to decode (15 s in the fuzz harness), so the cap came down. The scan-data check alone makes memory proportional to the input, but with a constant of a few KB per byte, so the cap is what keeps it small.

Why the VARC budget is per call: every walk is now linear in what the call reads (each condition table once, each subtable's scalars once, each variation's delta set once per application), and the budget caps the constant. Face::glyph_outline_at_coords makes up to 2,049 such calls for one glyph (its 2,048-component budget, in src/face/outlines.rs, which this branch does not touch), so a budget shared across one Face call would need that file.

Outputs on the parity fixtures are unchanged: the 432 Pillow files decode as before (432 of 432, identical to their baseline twins, same numbers against Pillow), the VARC fixture matches HarfBuzz at all 14 glyphs and eight locations, and the morx fixtures at all 25 strings. Tests count work instead of timing it: the 20-level chain costs 41 condition visits and 120,000 work units; five levels of 255 shared offsets cost 1,276 visits; 16 of 20 components varying through the 60,000-mention delta set fit the budget. Five JPEG structural-error tests whose scans carried no data now carry one byte, so they still reach the errors they test.

MultiVarStore::resolve_deltas changes meaning without changing its signature. It is public (re-exported as sigilbuzz::tables::MultiVarStore), and since c80920c it reads a delta set as one tuple per region, region after region, where it read one run of region deltas per output value. Semver tools cannot see that, so the changelog lines below call it out.

Verification

  • cargo fmt --all -- --check: clean.
  • cargo +1.99.0 clippy --workspace --all-targets --all-features -- -D warnings, ... --no-default-features ..., cargo +1.99.0 clippy -p sigilbuzz --no-default-features -- -D warnings: clean.
  • cargo test --workspace --all-features --no-fail-fast: 3,715 passed at 10724be; 3,721 passed, 0 failed, 9 ignored, 196 test binaries at 4aeb2a7. 4dd52b3 changes only the JPEG pixel cap; sigilbuzz-render tests and both of its clippy runs pass there, and CI is green on it.
  • cargo build --no-default-features: ok.
  • MSRV (CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback cargo +stable generate-lockfile, then cargo +1.81 check --workspace --lib --no-default-features and --lib --bins --all-features): ok.
  • Fuzzing (images, face, shape, 10 minutes each, -O -a, Docker): no crashes, timeouts or OOMs. images 114,085 runs (seeded with the Pillow progressive files), face 175,903 runs and shape 195,953 runs (seeded with the VARC, morx, sbix and COLRv1 variable fonts), 600 s each. After the review fixes: face 171,177 runs and shape 206,458 runs at 4aeb2a7 (seeds now include a 31 KB version of the condition-explosion font), no findings; images at 4aeb2a7 timed out on the 4096 x 4096 stream from the seeds, which led to 4dd52b3, and at 4dd52b3 ran 183,307 runs with no findings (seeds include every stream in the table above).
  • Parity after the review fixes: the 432 Pillow progressive files decode as before (432 of 432, equal to their baseline twins, same numbers against Pillow); tests/varc_parity.rs and tests/aat_morx_parity.rs pass unchanged.

Not done

  • Face::glyph_outline_at_coords (src/face/outlines.rs, frozen for this change) still calls Varc::composite with a component's coords at every level, so a RESET_UNSPECIFIED_AXES component one composite down inherits instead of starting from the font's coords, and a component naming its own glyph recurses to the depth cap and fails instead of drawing the glyph's plain outline (HarfBuzz's rule). The fix is to thread the top-level coords through glyph_outline_at_coords_inner, call composite_with_font_coords(glyph_id, coords, font_coords), and skip VARC for a component whose gid equals its parent's. tests/varc_parity.rs checks the nested case through composite_with_font_coords until then.
  • A MultiItemVariationStore delta set that ends before it fills its tuples adds nothing; HarfBuzz adds the values it finds. crates/sigilbuzz-subset/tests/varc_round_trip/mvs.rs builds such sets (one value for two fields) and expects no deltas, and that crate is out of scope here.
  • JPEG chroma upsampling stays nearest-neighbor; libjpeg's fancy upsampling differs by up to 95 levels at sharp chroma edges (baseline and progressive alike).
  • sigilbuzz-paint walks in HarfBuzz 11's callback order. HarfBuzz 14 pushes an ...AroundCenter transform as one composed push_transform and orders the root transform and clip differently; the values match once the transforms are composed.
  • AAT lookup formats 4, 8 and 10 are still unsupported, so a morx table that uses them for classes or substitutions does nothing there.
  • morx ligatures: HarfBuzz merges the clusters of non-component glyphs between the ligature's first and last component; sigilbuzz's origin model gives only the ligature the merged (earliest) origin.
  • The VARC work budget is per composite_with_font_coords call. Face::glyph_outline_at_coords can make up to 2,049 calls for one glyph; a budget shared across the whole Face call belongs in src/face/outlines.rs.

CHANGELOG lines

Added:

  • 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 (Fix the known JPEG, COLRv1, VARC and morx bugs #291).

Changed:

  • MultiVarStore::resolve_deltas 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 (Fix the known JPEG, COLRv1, VARC and morx bugs #291). It read the values of each output in turn, so a set varying two or more values over two or more regions resolved wrong. The signature is unchanged, so semver tools do not flag this: a caller that encoded delta sets value by value now gets different numbers.
  • 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 (Fix the known JPEG, COLRv1, VARC and morx bugs #291). JPEG data in fonts is sbix glyph images, far below these limits.

Fixed:

  • sigilbuzz-render decodes progressive JPEGs (Fix the known JPEG, COLRv1, VARC and morx bugs #291). 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 decodes AC refinement scans (Ah > 0), as T.81 G.1.2.3 and libjpeg read them (Fix the known JPEG, COLRv1, VARC and morx bugs #291). 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. The scans of a stream may visit at most 1024 coefficients per block of its frame, more than any valid script needs, and a DC refinement scan no longer needs a DC Huffman table.
  • sigilbuzz-paint resolves PaintVar*, VarColorStop and clip box deltas 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 (Fix the known JPEG, COLRv1, VARC and morx bugs #291).
  • VARC component axis values plus their deltas round to F2DOT14 (halves up) before they become the child's coords, as in HarfBuzz (Fix the known JPEG, COLRv1, VARC and morx bugs #291).
  • VARC records read as HarfBuzz reads them (Fix the known JPEG, COLRv1, VARC and morx bugs #291): 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, no deltas apply at the default instance, 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 conditions gate components (all five ConditionList formats, with a 64-level nesting limit), and RESET_UNSPECIFIED_AXES starts a component from the font's coords (Fix the known JPEG, COLRv1, VARC and morx bugs #291). Resolving one glyph evaluates each condition table once and works out each variation subtable's region scalars once, and its delta work is capped at 2^20 units (one per region index and per delta value walked), so a crafted condition tree or variation store cannot make one glyph take seconds.
  • sigilbuzz-render no longer spends seconds or gigabytes on small crafted JPEGs (Fix the known JPEG, COLRv1, VARC and morx bugs #291): repeated frames each re-sized the buffers and reset the scan budget, and COM padding after the frame header let a few hundred KB declare 16384 x 16384 (1.8 GB at peak).
  • 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 (Fix the known JPEG, COLRv1, VARC and morx bugs #291).
  • morx ligatures form as in HarfBuzz (Fix the known JPEG, COLRv1, VARC and morx bugs #291): 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" (Fix the known JPEG, COLRv1, VARC and morx bugs #291).

The progressive AC scan stored each coefficient at its natural
position, ZIGZAG[k], in a buffer that finalize_progressive reads in
zig-zag order and maps through ZIGZAG again. Every AC coefficient but
the last landed at the wrong frequency, so a progressive sbix JPEG with
any AC energy decoded to the wrong picture. The scan now stores the
coefficient at k.

Single-component scans also visited the whole MCU-padded block grid.
A one-component scan is non-interleaved (T.81 A.2.2): it codes only the
blocks that hold the component's samples, ceil(ceil(X * H / Hmax) / 8)
by ceil(ceil(Y * V / Vmax) / 8). A 4:2:0 frame 20 pixels wide has a
4-block luma MCU row but codes 3 blocks per row in its luma AC scans, so
reading 4 took the next row's bits. Progressive DC and AC scans of one
component and baseline frames of one component (grayscale that declares
2x2 sampling) now walk that grid.

A test-only encoder writes the same quantized coefficients as one
baseline scan and as progressive scripts. The progressive decode now
equals the baseline decode byte for byte for grayscale, 4:4:4, 4:2:2,
4:2:0 and 4:4:0 at sizes that end partway through a block or an MCU,
with EOB runs and ZRLs. Before the fix four of those tests failed.
A progressive scan with Ah > 0 over an AC band failed with "progressive
AC refinement scans not supported". libjpeg's default progression,
the one Pillow and most encoders write, refines every AC band twice,
so no progressive JPEG from them decoded. The decoder now reads AC
refinement scans as T.81 G.1.2.3 and libjpeg's decode_mcu_AC_refine do:
a run/size symbol with a sign bit makes a coefficient newly nonzero
at +-2^Al, each already-nonzero coefficient the walk passes reads one
correction bit, and an EOB run ends the band for its blocks while the
nonzero coefficients left in each still read their bits. A value whose
run walks past Se is dropped instead of being written outside the band.

Work stays bounded. Each loop step moves forward through the band or
ends the block, and an EOB run counts down once per block, so a scan
costs at most its band width per block whatever its EOB runs claim. All
the scans of a frame together may visit 1024 coefficients per block;
a valid script needs at most 896 (each of 64 coefficients coded once
and refined at most 13 times), so only a stream that repeats scans
runs out, and it fails with "progressive scans exceed work budget"
instead of walking the frame again for every few bytes. A DC refinement
scan reads raw bits and no longer requires a DC Huffman table, as
in libjpeg.

Of 432 progressive JPEGs written by Pillow 12.3.0 (libjpeg-turbo) over
nine sizes from 1x1 to 137x160, four pictures, grayscale and 4:4:4,
4:2:2 and 4:2:0 YCbCr, and quality 50, 90 and 100, none decoded
before and all decode now, each byte for byte equal to the decode
of its baseline twin (same quantized coefficients). Against Pillow's
own decode, grayscale is at most 1 level off (PSNR 61.4 dB or more)
and 4:4:4 at most 4 (49.9 dB), the integer-versus-float IDCT and
color conversion. Subsampled files differ by up to 95 levels at sharp
chroma edges, as their baseline twins do, from the nearest-neighbor
chroma upsampling.

The test encoder gains libjpeg's refinement encoder
(encode_mcu_AC_refine, with its EOB runs that carry correction bits),
libjpeg's default script, and a script that refines every bit in its
own pass. Hand-built streams cover an EOB run of 32,767 blocks in a
two-block frame, a new value past the band, a refinement scan cut
short, repeated scans, and a DC refinement scan whose table slot
is empty. Four Pillow progressive files and their baseline twins
are fixtures, from tests/tools/build_progressive_jpeg_fixtures.py;
.gitattributes marks *.jpg and *.raw binary so they never go through
EOL conversion.
HarfBuzz stores a font's coords as F2DOT14 integers and resolves every
variation there. Shaping and Face's outline and bounds methods round
coords that way since 0.23.1, but sigilbuzz-paint resolved PaintVar*,
VarColorStop and ClipBox deltas at the coords as given. A 16.16 coord
from Fvar::normalize_coords sits up to half an F2DOT14 step away, so a
varied transform, gradient or clip came out a little off the one
HarfBuzz paints, and off the outline it fills. Deltas now owns the
coords rounded to F2DOT14 (multiples of 1/16384, halves up, NaN read
as zero, all-zero read as the default instance), the rule the core
crate uses; the helper avoids f32::floor so the no_std build keeps
working.

VARC component axis values had the same gap one level down: each
value plus its deltas became the child's coord as value / 16384 with no
rounding, where HarfBuzz rounds the sum (roundf, floor(x + 0.5)) to a
whole F2DOT14 value. The values now stay in F2DOT14 units while the
deltas are added, round, and then divide. Axis value deltas also no
longer apply at the default instance, as HarfBuzz applies none when
the font has no coords.

Verification: Google's COLRv1 variable test font
(test_glyphs-glyf_colr_1_variable.ttf: 44 axes, 200 glyphs, every
PaintVar* format) walked with sigilbuzz-paint and with uharfbuzz's
paint_glyph (HarfBuzz 14.5.0) at three random off-grid locations,
comparing every color, gradient, stop and clip-glyph callback with the
transform in effect. Before, 34 to 37 of the 200 glyphs had a value
more than 0.001 off, the worst 0.54 units; now every value is within
0.00022 of HarfBuzz's. Nabla matches HarfBuzz at five EDPT and EHLT
settings before and after: its COLR carries no variation store, and
its outlines already rounded.
Comparing VARC outlines with HarfBuzz 14.5.0 over a test font built
from the boring-expansion spec turned up record and store differences
besides the ones issue #291 names:

- MultiItemVariationStore delta sets hold one tuple per region, region
  after region, as the spec, fontTools and HarfBuzz lay them out.
  resolve_deltas read them value after value, so any component varying
  two or more fields over two or more regions got the wrong deltas.
  Values past the last region's tuple are now ignored, as in HarfBuzz,
  where they used to drop every delta, and VARC adds the deltas into
  the stored values region by region as HarfBuzz does. A set that ends
  before it fills its tuples still adds nothing (HarfBuzz adds the
  values it finds); the subset crate's round-trip test relies on that.
- ScaleY defaults to ScaleX, not 1, so a component with only ScaleX
  scaled one direction.
- A reserved flag bit makes readers skip one uint32var after the
  transform fields. It used to end the glyph's component list.
- AXIS_VALUES_HAVE_VARIATION carries its index whether or not
  HAVE_AXES is set. It was only read inside HAVE_AXES, so the fields
  after it were read from the wrong bytes.
- No deltas apply at the default instance or for index 0xFFFFFFFF.
- An axis index past HarfBuzz's 4096-axis limit is ignored instead of
  growing the coord vector to 65536 values, an axis indices index past
  the list names no axes, axis indices decode as HarfBuzz walks them
  (lenient on truncation, negative values naming no axis), and a
  24-bit glyph id past 65535 draws nothing instead of wrapping to a low
  glyph.
- The sparse region falloff follows HarfBuzz's VarRegionAxis: a zero
  peak, an out-of-order triple or one crossing zero leave the axis
  unconstrained, and the default coord contributes zero.

Over 14 test glyphs at six locations, 6 to 8 glyphs per location drew
like HarfBuzz before (46 mismatches in all) and 12 after; the other
two need conditions and RESET_UNSPECIFIED_AXES, which the next commit
adds. Hand-built records
cover each case.
The ConditionList was read past and discarded, so every component drew
whatever its condition said, and RESET_UNSPECIFIED_AXES was ignored.
Both now follow the boring-expansion spec and HarfBuzz 14.5.0's
VarComponent::get_path_at.

A component with HAVE_CONDITION draws only when its condition holds at
the coords of the glyph it belongs to. Conditions are read from the
ConditionList (u32 count, Offset32 per condition) in all five formats:
axis range (whole F2DOT14 values, an axis past the coords at its
default), value (defaultValue plus the MultiItemVariationStore delta,
none at the default instance, holds when positive), And, Or and Negate
(Offset24 from the table). A missing list, an index past it, a null or
out-of-range offset name HarfBuzz's Null condition, which does not hold
(so its negation does); a table cut short, an unknown format, or a
table nested deeper than 64 levels (which HarfBuzz's sanitizer drops)
does not hold either. And and Or tables can share children, so one
call evaluates at most 65,536 condition tables, after which conditions
fail instead of walking 255^5 paths.

RESET_UNSPECIFIED_AXES starts the child's coords from the font's coords
instead of the parent glyph's, as does a glyph whose coords hold more
than 4096 axes, as in HarfBuzz. Varc::composite keeps its signature and
treats the coords it gets as the font's, which is right for a glyph
drawn on its own; the new Varc::composite_with_font_coords takes the
font's coords separately for a glyph reached through another composite.
Face::glyph_outline_at_coords (src/face/outlines.rs) still calls
composite with the component's coords, so a nested reset component
keeps inheriting there until that walk passes the font's coords down.

Against HarfBuzz on the VARC test font from the previous commit (14
glyphs, eight locations): through Face 13 of 14 glyphs now match at
every location (the conditions glyph included); the nested reset glyph
differs as described. Flattening through composite_with_font_coords
with the font's coords matches all 14 at all eight. Hand-built tables
cover every condition format, null and out-of-range references,
truncation, 64-level nesting, the shared-tree budget, and reset with
and without axes.
A contextual (type 1) subtable's substitution table is an unsized array
of u32 offsets, one per lookup, each from the start of the table, as
the spec has it and HarfBuzz reads it (UnsizedListOfOffset16To with
HBUINT32 offsets). sub_lookup read a u16 count first and the offsets
after it, so the count came from the high half of the first offset
(0 for any table under 64 KiB) and no contextual substitution ever
applied; with a larger first offset every lookup read the wrong bytes.

The rest of the subtable now follows HarfBuzz's ContextualSubtable
too. At the end of text nothing is substituted unless a mark was set
(CoreText's rule, which HarfBuzz copies), and the current substitution
there applies to the last glyph instead of nothing. The mark starts on
glyph 0, so a mark substitution before any SetMark replaces the first
glyph, as in HarfBuzz.

On a crafted AAT font (no GSUB, contextual morx with mark, current,
end-of-text and unset-mark entries), sigilbuzz matched HarfBuzz 14.5.0
on 2 of 11 strings before and 11 of 11 after, glyph ids and clusters.
Hand-built subtables cover the offset array, an index past it, the
end-of-text rule with and without a mark, and the initial mark.
A ligature entry with DontAdvance can set the same glyph as a
component again. The component stack took it twice, and the action
sorted the positions it popped without removing duplicates, so it put
the ligature on the doubled glyph and then removed it as a "later"
component, or removed a neighbour once earlier removals had shifted
the run. On a crafted font "lx" (l pushed, x pushed with DontAdvance
and again with the action) came out as "l".

The ligature subtable now follows HarfBuzz's LigatureSubtable:

- SetComponent never pushes the position already on top of the stack.
- The stack is HarfBuzz's 64-entry ring of positions.
- An action with Store or Last puts the ligature in place of the
  component it popped and deletes the components popped since, and the
  ligature stays on the stack, so "ffi" can form from "ff" and "i".
  The ligature index runs on across stores. Store used to count only
  together with Last, and the stack lost every component it popped.
- Running out of components clears the stack; no action runs with an
  empty stack or at the end of text.
- Deleted components become DELETED_GLYPH in place, so positions stay
  put, later subtables see them in the deleted-glyph class, and
  Morx::apply removes them after the last chain, as HarfBuzz removes
  them after morx. The ligature takes the earliest origin among the
  glyphs whose clusters HarfBuzz merges into it.

On the crafted AAT font (ligatures that cascade, a component set
twice, Store without Last, an action at end of text), sigilbuzz
matched HarfBuzz 14.5.0 on 5 of 14 strings before and 14 of 14 after,
glyph ids and clusters. Hand-built subtables cover each rule; five of
them failed before.
The AAT lookup reader returns the out-of-bounds class, 1, for a glyph
the lookup does not cover. The contextual and non-contextual
substitution subtables took that value for "no substitution", so a
lookup that maps a glyph to glyph 1 was dropped. HarfBuzz's
Lookup::get_value tells the two apart.

state_table::lookup_value now returns None for an uncovered glyph and
Some(value) otherwise, lookup_class maps None to the out-of-bounds
class as before (kerx, ankr and the state tables see no change), and
both substitution subtables read lookup_value. On a crafted font whose
type 4 subtable maps b to a (glyph 1), "bx" now shapes to glyphs 1 and
13 as in HarfBuzz 14.5.0, where it kept b.
Three synthetic fonts, written byte by byte from the VARC and morx
specs by tests/tools/build_varc_morx_parity_fixtures.py, with
HarfBuzz 14.5.0's output recorded by
tests/tools/varc_morx_parity_expected.py:

- varc_parity.ttf: three axes, 14 VARC glyphs over two gvar-varied
  glyphs, covering every component record field (ScaleY defaulting to
  ScaleX, skew, center, axis values with deltas over several regions,
  transform deltas, an axis-values index without axes, nesting,
  conditions of all five formats, reserved flag bits,
  RESET_UNSPECIFIED_AXES at the top and one level down, an axis past
  4096). tests/varc_parity.rs compares all 14 outlines at eight
  locations within 0.01 units. The nested reset glyph goes through
  Varc::composite_with_font_coords, since Face::glyph_outline_at_coords
  does not pass the font's coords down yet.
- aat_morx_contextual.ttf and aat_morx_ligature.ttf: AAT fonts without
  GSUB. tests/aat_morx_parity.rs shapes 25 strings and compares glyph
  ids and clusters.

The builds are deterministic (head timestamps pinned).
@albedosehen albedosehen added this to the 0.24.0 milestone Oct 4, 2026
A JPEG stream holds one frame, and libjpeg stops at a second SOF
(JERR_SOF_DUPLICATE). The decoder took each SOF2 as a fresh frame: it
re-sized the coefficient buffers and reset the progressive scan budget,
so a stream of N frames each followed by 16 full-band refinement scans
cost N times blocks times 1008 coefficient visits, quadratic in the
stream length. A crafted 4 KB stream took 0.3 s and a 16 KB one 6.4 s;
the review measured 1.3 s, 32.4 s and over 240 s at 4, 16 and 64 KB.

A second SOF is now BadJpeg("second SOF"), before its segment is read,
so the 1024-visits-per-block budget bounds the whole stream, and the
module docs say so instead of reading as a per-frame bound. A test
repeats the frame header of a progressive stream after its last scan
and of a baseline stream before its scan.
The frame-size check let a header declare eight blocks per byte left in
the stream, so COM or APPn padding after the SOF counted as data: a
574 KB stream declaring 16384 x 16384 grayscale progressive, padded
with comments, decoded and peaked at 1.8 GB (measured 1,797 MB, 4.0 s).
The blocks are now checked against the entropy-coded bytes actually
present: entropy_bytes_after walks the marker segments after the SOF
by their lengths and counts only the bytes inside SOS segments, up to
the next real marker. That stream now fails with "frame larger than
entropy data" before any allocation (0.0 ms, 1 MB).

A frame is also capped at 2^24 pixels (4096 x 4096), "frame larger
than pixel limit". JPEG data in a font is an sbix glyph image for one
strike's ppem, and a 4096-pixel strike is sixteen times the area of a
1024 ppem one. A decode holds at most 13 bytes per pixel (a progressive
4:4:4 frame: 6 of coefficients, 3 of sample planes, 4 of RGBA output),
so one decode stays near 220 MB. The worst stream that passes both
checks, a 33 KB grayscale 4096 x 4096 frame with 16 full-band
refinement scans over one bit of DC data per block, decodes in 0.8 s
at 113 MB; before, a 64 KB stream of that shape decoded 5768 x 5768 in
1.4 s at 224 MB.

Tests: COM padding does not back a frame and real scan data does,
entropy_bytes_after counts stuffed bytes, stops at EOI and at a byte
where a marker should start, and 4097 x 4097 is rejected in both modes
while 4096 x 4096 passes. Five structural-error tests whose scans
carried no data at all get one byte of scan data, so they still reach
the errors they test.
Conditions, new in this branch, could make one glyph run for many
seconds. The 65,536-visit budget counted condition tables, but a Value
condition cost a pass over every region index of its subtable, and a
shared subtree was walked once per path. A 182 KB font with one gated
component under 20 And tables whose two offsets name the same child
(2^20 paths), over a Value condition whose subtable names one region
60,000 times, took 22.5 s for one Face::glyph_outline_at_coords and
then dropped the component when the visit budget ran out (HarfBuzz
14.5.0 takes 516 s on the review's version of it).

Each composite_with_font_coords call now keeps an evaluation state:

- Condition results by table offset. A result depends only on the
  coords, so a table reached again through another path is read back;
  the 2^20 paths cost 41 visits.
- Region scalars by MultiItemVariationData subtable, worked out once
  per call (MultiVarStore gains crate-internal slot_scalars and
  add_slot_deltas for this).
- A work budget of 2^20 units: one per region index whose scalar is
  worked out and one per delta value walked (region indexes times
  values) for each variation applied. Past it no more deltas apply
  and Value conditions that need them fail. A component of a real
  font walks a few hundred values.

The font above now takes 1.6 ms and shows its component, as HarfBuzz
does. Deltas are still added into the stored values region by region,
so outputs do not change: the VARC parity fixture still matches
HarfBuzz at all 14 glyphs and eight locations.

Tests count the work instead of timing it: the 20-level chain costs 41
visits and 120,000 units (60,000 scalar steps, 60,000 delta values);
five levels of 255 shared offsets cost 1,276 visits and the component
shows; and of twenty components varying through the 60,000-mention
delta set, the first 16 fit the budget and the rest keep their stored
translation. The old test that expected the shared tree to exhaust
the budget is replaced by the first of these.
The 2^24-pixel cap from the previous commit still let a 33 KB stream
decode 4096 x 4096 (0.8 s, 113 MB), and the images fuzz target, which
also re-encodes every decoded picture as PNG, timed out on such a
stream after 15 s under its sanitizer build. Glyph images never get
near that: a 2048-pixel sbix strike would already be four times the
area of a 1024 ppem one. The cap is now 2^22 pixels, 2048 x 2048,
which keeps a decode near 55 MB at most (13 bytes per pixel for a
progressive 4:4:4 frame).

The worst stream that passes both checks, an 8 KB 2048 x 2048
grayscale frame with 16 full-band refinement scans over one bit of DC
data per block, now decodes in 0.10 s at 29 MB, and the fuzz body
(decode, PNG round trip, rescale) takes 0.25 s on it with debug
assertions. The 4096 x 4096 stream fails with "frame larger than pixel
limit". The pixel-limit test moves to 2049 x 2049 and 2048 x 2048.
@albedosehen
albedosehen marked this pull request as ready for review October 4, 2026 20:06
@albedosehen
albedosehen merged commit f626c2e into main Oct 4, 2026
8 checks passed
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.

Fix the known JPEG, COLRv1, VARC and morx bugs

1 participant