Repository navigation
Fix the known JPEG, COLRv1, VARC and morx bugs - #293
Merged
Merged
Conversation
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).
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
marked this pull request as ready for review
October 4, 2026 20:06
This was referenced Oct 4, 2026
Merged
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #291
Fixes the known output-changing bugs outside the shaping path (audit items A15, A2, B9, A16, A17), plus the VARC and
morxdifferences that comparing with HarfBuzz 14.5.0 turned up while verifying them. One commit per item, plus three commits for the review of10724be(see Review fixes).Commits
k, notZIGZAG[k]; one-component scans walk the component's own block grid (T.81 A.2.2), progressive and baselinedecode_mcu_AC_refine; a 1024-visits-per-block budget for a frame's scans; DC refinement needs no DC tablesigilbuzz-paintdeltas read F2DOT14 coords; VARC axis value plus deltas rounds (floor(x + 0.5)) before it becomes a coordVarc::composite_with_font_coordsLigatureSubtable: no double push, ligature stays on the stack, Store or Last stores, deleted glyphs kept until morx endsEvidence, 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.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 bysigilbuzz-paintand byhb_font_paint_glyphat three random off-grid locations, comparing every color, gradient, stop and clip-glyph callback with the transform in effect: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_f2dot14fails 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.Face, 14 of 14 throughcomposite_with_font_coords(8 locations)A17 (morx), against HarfBuzz's AAT shaper (glyph ids and clusters).
Review fixes
Four follow-up commits from the independent review of head
10724be, and one note.BadJpeg("second SOF"), as libjpeg's JERR_SOF_DUPLICATE, so the 1024-visits-per-block scan budget bounds the whole stream; docs say so10724beFace::glyph_outline_at_coordssecond SOF, 36 msframe larger than entropy data, 0.0 ms, peak 1 MBframe larger than pixel limit, 0.0 msframe larger than pixel limit, 0.1 msWhy 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_coordsmakes 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_deltaschanges meaning without changing its signature. It is public (re-exported assigilbuzz::tables::MultiVarStore), and sincec80920cit 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 at10724be; 3,721 passed, 0 failed, 9 ignored, 196 test binaries at4aeb2a7.4dd52b3changes only the JPEG pixel cap;sigilbuzz-rendertests and both of its clippy runs pass there, and CI is green on it.cargo build --no-default-features: ok.CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback cargo +stable generate-lockfile, thencargo +1.81 check --workspace --lib --no-default-featuresand--lib --bins --all-features): ok.images,face,shape, 10 minutes each,-O -a, Docker): no crashes, timeouts or OOMs.images114,085 runs (seeded with the Pillow progressive files),face175,903 runs andshape195,953 runs (seeded with the VARC, morx, sbix and COLRv1 variable fonts), 600 s each. After the review fixes:face171,177 runs andshape206,458 runs at4aeb2a7(seeds now include a 31 KB version of the condition-explosion font), no findings;imagesat4aeb2a7timed out on the 4096 x 4096 stream from the seeds, which led to4dd52b3, and at4dd52b3ran 183,307 runs with no findings (seeds include every stream in the table above).tests/varc_parity.rsandtests/aat_morx_parity.rspass unchanged.Not done
Face::glyph_outline_at_coords(src/face/outlines.rs, frozen for this change) still callsVarc::compositewith 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 throughglyph_outline_at_coords_inner, callcomposite_with_font_coords(glyph_id, coords, font_coords), and skip VARC for a component whose gid equals its parent's.tests/varc_parity.rschecks the nested case throughcomposite_with_font_coordsuntil then.crates/sigilbuzz-subset/tests/varc_round_trip/mvs.rsbuilds such sets (one value for two fields) and expects no deltas, and that crate is out of scope here.sigilbuzz-paintwalks in HarfBuzz 11's callback order. HarfBuzz 14 pushes an...AroundCentertransform as one composedpush_transformand orders the root transform and clip differently; the values match once the transforms are composed.morxtable that uses them for classes or substitutions does nothing there.morxligatures: 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.composite_with_font_coordscall.Face::glyph_outline_at_coordscan 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_coordsresolves a VARC glyph reached through another composite: its own coords and the font's are given separately, soRESET_UNSPECIFIED_AXEScomponents start from the font's coords (Fix the known JPEG, COLRv1, VARC and morx bugs #291).Changed:
MultiVarStore::resolve_deltasreads 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_jpegrejects 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 asRenderError::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-renderdecodes 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-renderdecodes 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-paintresolvesPaintVar*,VarColorStopand 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).RESET_UNSPECIFIED_AXESstarts 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-renderno 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).morxcontextual 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).morxligatures 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.morxsubstitutions 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).