From 345598a9854b69f075f3179be8d567fffd8c0ee1 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 08:27:53 -0800 Subject: [PATCH 01/13] Decode progressive JPEG AC coefficients at their zig-zag index 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. --- .../src/jpeg_decode/baseline.rs | 21 +- .../src/jpeg_decode/progressive.rs | 58 ++- .../sigilbuzz-render/src/jpeg_decode/tests.rs | 1 + .../src/jpeg_decode/tests/encode.rs | 389 ++++++++++++++++++ .../src/jpeg_decode/tests/progressive.rs | 126 ++++++ 5 files changed, 578 insertions(+), 17 deletions(-) create mode 100644 crates/sigilbuzz-render/src/jpeg_decode/tests/encode.rs diff --git a/crates/sigilbuzz-render/src/jpeg_decode/baseline.rs b/crates/sigilbuzz-render/src/jpeg_decode/baseline.rs index 0e010fa5..82cb022e 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode/baseline.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode/baseline.rs @@ -101,13 +101,26 @@ impl Decoder<'_> { let mut bit_reader = BitReader::new(self.src.get(self.cursor..).unwrap_or_default()); let mut prev_dc = vec![0i32; self.components.len()]; - for mcu_y in 0..mcus_y { - for mcu_x in 0..mcus_x { + // A scan of one component is non-interleaved (T.81 A.2.2): each + // MCU is one block, and the blocks run over the component's own + // sample grid whatever sampling factors the frame declares. + let single = self.components.len() == 1; + let (scan_mcus_x, scan_mcus_y) = if single { + (self.width.div_ceil(8), self.height.div_ceil(8)) + } else { + (mcus_x, mcus_y) + }; + + for mcu_y in 0..scan_mcus_y { + for mcu_x in 0..scan_mcus_x { for (ci, (comp, &(dc_tbl, ac_tbl, qt))) in self.components.iter().zip(&tables).enumerate() { - let h = u32::from(comp.h_sampling); - let v = u32::from(comp.v_sampling); + let (h, v) = if single { + (1, 1) + } else { + (u32::from(comp.h_sampling), u32::from(comp.v_sampling)) + }; for by in 0..v { for bx in 0..h { let mut coeffs = [0i32; 64]; diff --git a/crates/sigilbuzz-render/src/jpeg_decode/progressive.rs b/crates/sigilbuzz-render/src/jpeg_decode/progressive.rs index 4561d0ec..df73860d 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode/progressive.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode/progressive.rs @@ -141,10 +141,11 @@ impl<'a> Decoder<'a> { let (mcus_x, mcus_y) = self.mcu_grid(); let mut prev_dc = vec![0i32; self.components.len()]; - // Single-component scans iterate the component's own block - // grid; multi-component scans walk in MCU order. + // Single-component scans are non-interleaved: they visit only + // the blocks that hold the component's samples. Multi-component + // scans walk in MCU order. if let &[ci] = scan_indices { - let (bw, bh) = self.blocks_per_comp.get(ci).copied().unwrap_or((0, 0)); + let (bw, bh) = self.scan_blocks(ci); for by in 0..bh { for bx in 0..bw { self.decode_dc_first_block(br, ci, bx, by, &mut prev_dc[ci], al)?; @@ -215,6 +216,34 @@ impl<'a> Decoder<'a> { self.coeffs.get_mut(ci)?.get_mut(block_idx.checked_mul(64)?) } + /// Block columns and rows a non-interleaved (single-component) + /// scan visits for component `ci`: the blocks that hold the + /// component's own samples, `ceil(ceil(X * H / Hmax) / 8)` by + /// `ceil(ceil(Y * V / Vmax) / 8)` (T.81 A.2.2). An interleaved + /// scan pads every component out to whole MCUs, so this can be + /// smaller than the stored grid, never larger. + fn scan_blocks(&self, ci: usize) -> (u32, u32) { + let (Some(comp), Some(&(bw, bh))) = (self.components.get(ci), self.blocks_per_comp.get(ci)) + else { + return (0, 0); + }; + let max_h = self + .components + .iter() + .map(|c| u32::from(c.h_sampling)) + .max() + .unwrap_or(1); + let max_v = self + .components + .iter() + .map(|c| u32::from(c.v_sampling)) + .max() + .unwrap_or(1); + let comp_w = (self.width * u32::from(comp.h_sampling)).div_ceil(max_h); + let comp_h = (self.height * u32::from(comp.v_sampling)).div_ceil(max_v); + (comp_w.div_ceil(8).min(bw), comp_h.div_ceil(8).min(bh)) + } + /// Refinement DC scan (Ah > 0). Reads one bit per block and ORs /// it into bit position `al` of the existing DC coefficient. fn scan_dc_refine( @@ -226,7 +255,7 @@ impl<'a> Decoder<'a> { let (mcus_x, mcus_y) = self.mcu_grid(); if let &[ci] = scan_indices { - let (bw, bh) = self.blocks_per_comp.get(ci).copied().unwrap_or((0, 0)); + let (bw, bh) = self.scan_blocks(ci); for by in 0..bh { for bx in 0..bw { self.refine_dc_block(br, ci, bx, by, al); @@ -262,9 +291,12 @@ impl<'a> Decoder<'a> { } } - /// First-pass AC scan (Ah == 0). Walks the component's blocks in - /// row-major order and decodes run/value pairs over the band - /// `ss..=se`, including EOB-run tracking for large skips. + /// First-pass AC scan (Ah == 0). An AC scan holds one component + /// and is non-interleaved, so it walks the blocks that hold the + /// component's samples in row-major order and decodes run/value + /// pairs over the band `ss..=se`, including EOB-run tracking for + /// large skips. Each value lands at its zig-zag index, the order + /// [`Self::finalize_progressive`] reads the buffer in. fn scan_ac_first( &mut self, br: &mut BitReader<'_>, @@ -274,16 +306,17 @@ impl<'a> Decoder<'a> { al: u8, ) -> Result<(), RenderError> { let comp = self.components[ci]; - let (bw, bh) = self.blocks_per_comp.get(ci).copied().unwrap_or((0, 0)); + let (cols, rows) = self.scan_blocks(ci); + let stride = self.blocks_per_comp.get(ci).map_or(0, |&(bw, _)| bw); let ac_tbl = table_slot(&self.ac_huff, comp.ac_huff) .ok_or(RenderError::BadJpeg("missing AC Huffman table"))?; let Some(coeffs) = self.coeffs.get_mut(ci) else { return Ok(()); }; let mut eob_run: u32 = 0; - for by in 0..bh { - for bx in 0..bw { - let block_idx = (by * bw + bx) as usize; + for by in 0..rows { + for bx in 0..cols { + let block_idx = (by * stride + bx) as usize; let coeff_off = block_idx * 64; if eob_run > 0 { eob_run -= 1; @@ -313,8 +346,7 @@ impl<'a> Decoder<'a> { } let raw = br.read_bits(size); let val = extend(raw, size); - let nat = ZIGZAG[k as usize]; - if let Some(slot) = coeffs.get_mut(coeff_off + nat) { + if let Some(slot) = coeffs.get_mut(coeff_off + usize::from(k)) { *slot = (val << al) as i16; } k = k.saturating_add(1); diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests.rs b/crates/sigilbuzz-render/src/jpeg_decode/tests.rs index ca7d110e..b4d12f2d 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode/tests.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests.rs @@ -6,6 +6,7 @@ use super::idct::idct; use super::*; mod adversarial; +mod encode; mod progressive; /// Build a minimal baseline JPEG that encodes a single 8x8 block diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/encode.rs b/crates/sigilbuzz-render/src/jpeg_decode/tests/encode.rs new file mode 100644 index 00000000..90fbe5fd --- /dev/null +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests/encode.rs @@ -0,0 +1,389 @@ +//! Test-only JPEG encoder. It writes given quantized coefficients as a +//! baseline (SOF0) stream or as a progressive (SOF2) stream under any +//! scan script, so decode tests can check that both paths rebuild the +//! same coefficients. Entropy coding follows T.81 Annex G and the +//! libjpeg encoder (`jcphuff.c`), EOB runs included. + +use super::*; + +/// One frame's quantized coefficients. Every component's blocks cover +/// its whole MCU-padded grid, row-major, each in zig-zag order. The +/// quantization table is all ones, so a coefficient is its dequantized +/// value. +pub(super) struct Image { + pub(super) width: u16, + pub(super) height: u16, + /// `(h, v)` sampling factors per component. + pub(super) sampling: Vec<(u8, u8)>, + pub(super) blocks: Vec>, +} + +/// One scan of a progressive script: the components it covers, the +/// band `ss..=se`, and the successive-approximation bits. +#[derive(Clone)] +pub(super) struct Scan { + pub(super) comps: Vec, + pub(super) ss: u8, + pub(super) se: u8, + pub(super) ah: u8, + pub(super) al: u8, +} + +pub(super) const fn scan(comps: Vec, ss: u8, se: u8, ah: u8, al: u8) -> Scan { + Scan { + comps, + ss, + se, + ah, + al, + } +} + +impl Image { + fn max_sampling(&self) -> (u32, u32) { + let h = self.sampling.iter().map(|s| u32::from(s.0)).max(); + let v = self.sampling.iter().map(|s| u32::from(s.1)).max(); + (h.unwrap_or(1), v.unwrap_or(1)) + } + + fn mcus(&self) -> (u32, u32) { + let (h, v) = self.max_sampling(); + ( + u32::from(self.width).div_ceil(8 * h), + u32::from(self.height).div_ceil(8 * v), + ) + } + + /// The MCU-padded block grid of component `ci`. + fn grid(&self, ci: usize) -> (u32, u32) { + let (mx, my) = self.mcus(); + let (h, v) = self.sampling[ci]; + (mx * u32::from(h), my * u32::from(v)) + } + + /// The blocks a non-interleaved scan of component `ci` visits. + fn scan_grid(&self, ci: usize) -> (u32, u32) { + let (mh, mv) = self.max_sampling(); + let (h, v) = self.sampling[ci]; + let w = (u32::from(self.width) * u32::from(h)).div_ceil(mh); + let hh = (u32::from(self.height) * u32::from(v)).div_ceil(mv); + (w.div_ceil(8), hh.div_ceil(8)) + } + + /// `(component, block index)` in the order a scan over `comps` + /// visits them: MCU order when it interleaves several components, + /// the component's own sample grid when it holds one. + fn scan_order(&self, comps: &[usize]) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + if let &[ci] = comps { + let (cols, rows) = self.scan_grid(ci); + let (stride, _) = self.grid(ci); + for by in 0..rows { + for bx in 0..cols { + out.push((ci, (by * stride + bx) as usize)); + } + } + return out; + } + let (mx, my) = self.mcus(); + for mcu_y in 0..my { + for mcu_x in 0..mx { + for &ci in comps { + let (h, v) = self.sampling[ci]; + let (stride, _) = self.grid(ci); + for by in 0..u32::from(v) { + for bx in 0..u32::from(h) { + let x = mcu_x * u32::from(h) + bx; + let y = mcu_y * u32::from(v) + by; + out.push((ci, (y * stride + x) as usize)); + } + } + } + } + } + out + } + + /// Pseudo-random coefficients in `-amplitude..=amplitude`, with + /// about one coefficient in `sparsity` nonzero past the DC, so + /// scans see runs of zeros, ZRLs, and EOB runs. + pub(super) fn random( + width: u16, + height: u16, + sampling: &[(u8, u8)], + seed: u32, + amplitude: i16, + sparsity: u32, + ) -> Self { + let mut img = Self { + width, + height, + sampling: sampling.to_vec(), + blocks: Vec::new(), + }; + let mut state = seed | 1; + let mut next = move || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state + }; + let span = 2 * u32::from(amplitude.unsigned_abs()) + 1; + for ci in 0..sampling.len() { + let (bw, bh) = img.grid(ci); + let mut blocks = Vec::new(); + for _ in 0..bw * bh { + let mut block = [0i16; 64]; + for (k, c) in block.iter_mut().enumerate() { + if k == 0 || next() % sparsity == 0 { + *c = (next() % span) as i16 - amplitude; + } + } + blocks.push(block); + } + img.blocks.push(blocks); + } + img + } +} + +/// A Huffman table that codes all 256 symbols: 128 eight-bit codes and +/// 128 nine-bit codes. Returns the DHT counts and symbols. +fn flat_table() -> ([u8; 16], Vec) { + let mut counts = [0u8; 16]; + counts[7] = 128; + counts[8] = 128; + (counts, (0..=255).collect()) +} + +/// Canonical `(code, length)` per symbol of [`flat_table`]. +fn flat_codes() -> Vec<(u32, u8)> { + let (counts, symbols) = flat_table(); + let mut codes = vec![(0u32, 0u8); 256]; + let mut code = 0u32; + let mut next = symbols.iter(); + for (len_minus_one, &count) in counts.iter().enumerate() { + for _ in 0..count { + let sym = *next.next().unwrap(); + codes[usize::from(sym)] = (code, len_minus_one as u8 + 1); + code += 1; + } + code <<= 1; + } + codes +} + +/// Magnitude category and the bits that follow it (T.81 F.1.2.1). +fn magnitude(v: i32) -> (u8, u32) { + if v == 0 { + return (0, 0); + } + let size = 32 - v.unsigned_abs().leading_zeros(); + let bits = if v < 0 { v - 1 } else { v } as u32 & ((1u32 << size) - 1); + (size as u8, bits) +} + +struct Entropy { + out: BitWriter, + codes: Vec<(u32, u8)>, +} + +impl Entropy { + fn new() -> Self { + Self { + out: BitWriter::default(), + codes: flat_codes(), + } + } + + fn symbol(&mut self, sym: u8) { + let (code, len) = self.codes[usize::from(sym)]; + self.out.write_bits(code, len); + } + + fn bits(&mut self, bits: u32, n: u8) { + if n > 0 { + self.out.write_bits(bits, n); + } + } + + fn finish(mut self) -> Vec { + self.out.flush(); + self.out.bytes + } +} + +/// SOI, DQT, SOF, and DHT for `img`. +fn header(img: &Image, sof: u8) -> Vec { + let mut out = vec![0xFF, MARKER_SOI, 0xFF, MARKER_DQT, 0x00, 67, 0x00]; + out.extend_from_slice(&[1u8; 64]); + let n = img.sampling.len() as u8; + out.extend_from_slice(&[0xFF, sof]); + out.extend_from_slice(&(8u16 + 3 * u16::from(n)).to_be_bytes()); + out.push(8); + out.extend_from_slice(&img.height.to_be_bytes()); + out.extend_from_slice(&img.width.to_be_bytes()); + out.push(n); + for (i, &(h, v)) in img.sampling.iter().enumerate() { + out.extend_from_slice(&[i as u8 + 1, (h << 4) | v, 0]); + } + let (counts, symbols) = flat_table(); + for class in [0x00u8, 0x10] { + out.extend_from_slice(&[0xFF, MARKER_DHT]); + out.extend_from_slice(&(2u16 + 17 + symbols.len() as u16).to_be_bytes()); + out.push(class); + out.extend_from_slice(&counts); + out.extend_from_slice(&symbols); + } + out +} + +fn sos(out: &mut Vec, comps: &[usize], ss: u8, se: u8, ah: u8, al: u8) { + out.extend_from_slice(&[0xFF, MARKER_SOS]); + out.extend_from_slice(&(6u16 + 2 * comps.len() as u16).to_be_bytes()); + out.push(comps.len() as u8); + for &ci in comps { + out.extend_from_slice(&[ci as u8 + 1, 0x00]); + } + out.extend_from_slice(&[ss, se, (ah << 4) | al]); +} + +/// `img` as a baseline (SOF0) stream: one interleaved scan of every +/// component. +pub(super) fn encode_baseline(img: &Image) -> Vec { + let mut out = header(img, MARKER_SOF0); + let comps: Vec = (0..img.sampling.len()).collect(); + sos(&mut out, &comps, 0, 63, 0, 0); + let mut e = Entropy::new(); + let mut pred = vec![0i32; comps.len()]; + for (ci, b) in img.scan_order(&comps) { + let block = &img.blocks[ci][b]; + let dc = i32::from(block[0]); + let (size, bits) = magnitude(dc - pred[ci]); + pred[ci] = dc; + e.symbol(size); + e.bits(bits, size); + let mut run = 0u8; + for &c in &block[1..] { + if c == 0 { + run += 1; + continue; + } + while run > 15 { + e.symbol(0xF0); + run -= 16; + } + let (size, bits) = magnitude(i32::from(c)); + e.symbol((run << 4) | size); + e.bits(bits, size); + run = 0; + } + if run > 0 { + e.symbol(0x00); + } + } + out.extend_from_slice(&e.finish()); + out.extend_from_slice(&[0xFF, MARKER_EOI]); + out +} + +/// `img` as a progressive (SOF2) stream under `script`. +pub(super) fn encode_progressive(img: &Image, script: &[Scan]) -> Vec { + let mut out = header(img, MARKER_SOF2); + for s in script { + sos(&mut out, &s.comps, s.ss, s.se, s.ah, s.al); + let mut e = Entropy::new(); + let order = img.scan_order(&s.comps); + match (s.ss, s.ah) { + (0, 0) => dc_first(&mut e, img, &order, s.al), + (0, _) => { + for &(ci, b) in &order { + e.bits((img.blocks[ci][b][0] >> s.al) as u32 & 1, 1); + } + } + (_, 0) => ac_first(&mut e, img, &order, s), + _ => panic!("AC refinement scans are not encoded yet"), + } + out.extend_from_slice(&e.finish()); + } + out.extend_from_slice(&[0xFF, MARKER_EOI]); + out +} + +fn dc_first(e: &mut Entropy, img: &Image, order: &[(usize, usize)], al: u8) { + let mut pred = vec![0i32; img.sampling.len()]; + for &(ci, b) in order { + // Arithmetic shift, as libjpeg's point transform for DC. + let dc = i32::from(img.blocks[ci][b][0]) >> al; + let (size, bits) = magnitude(dc - pred[ci]); + pred[ci] = dc; + e.symbol(size); + e.bits(bits, size); + } +} + +/// Writes a pending EOB run: the EOBn symbol and its extra bits. +fn emit_eob_run(e: &mut Entropy, eob_run: &mut u32) { + if *eob_run == 0 { + return; + } + let nbits = 31 - eob_run.leading_zeros(); + e.symbol((nbits as u8) << 4); + e.bits(*eob_run, nbits as u8); + *eob_run = 0; +} + +/// AC point transform: the magnitude shifted right, sign kept. +fn ac_point(c: i16, al: u8) -> i32 { + let m = i32::from(c.unsigned_abs()) >> al; + if c < 0 { + -m + } else { + m + } +} + +fn ac_first(e: &mut Entropy, img: &Image, order: &[(usize, usize)], s: &Scan) { + let mut eob_run = 0u32; + for &(ci, b) in order { + let block = &img.blocks[ci][b]; + let mut run = 0u8; + for &c in &block[usize::from(s.ss)..=usize::from(s.se)] { + let v = ac_point(c, s.al); + if v == 0 { + run += 1; + continue; + } + emit_eob_run(e, &mut eob_run); + while run > 15 { + e.symbol(0xF0); + run -= 16; + } + let (size, bits) = magnitude(v); + e.symbol((run << 4) | size); + e.bits(bits, size); + run = 0; + } + if run > 0 { + eob_run += 1; + if eob_run == 0x7FFF { + emit_eob_run(e, &mut eob_run); + } + } + } + emit_eob_run(e, &mut eob_run); +} + +/// Spectral selection only, every scan at full precision: an +/// interleaved DC scan, then two AC bands per component. +pub(super) fn spectral_script(n_comps: usize) -> Vec { + let all: Vec = (0..n_comps).collect(); + let mut script = vec![scan(all, 0, 0, 0, 0)]; + for ci in (0..n_comps).rev() { + script.push(scan(vec![ci], 1, 5, 0, 0)); + } + for ci in 0..n_comps { + script.push(scan(vec![ci], 6, 63, 0, 0)); + } + script +} diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs b/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs index 0623f677..851f14b2 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs @@ -1,7 +1,9 @@ //! Progressive (SOF2) decode tests built from synthetic two-scan //! streams. +use super::encode::{encode_baseline, encode_progressive, scan, spectral_script, Image}; use super::*; +use alloc::format; /// Build a minimal **progressive** (SOF2) grayscale JPEG that /// encodes a single 8x8 block via two scans: @@ -270,3 +272,127 @@ fn progressive_ac_refinement_scan_is_unsupported() { RenderError::BadJpeg("progressive AC refinement scans not supported") )); } + +/// Sampling layouts the decoder accepts: grayscale, then YCbCr 4:4:4, +/// 4:2:2, 4:2:0, and 4:4:0, then grayscale that declares 2x2 sampling, +/// which a one-component frame ignores. +const LAYOUTS: [&[(u8, u8)]; 6] = [ + &[(1, 1)], + &[(1, 1), (1, 1), (1, 1)], + &[(2, 1), (1, 1), (1, 1)], + &[(2, 2), (1, 1), (1, 1)], + &[(1, 2), (1, 1), (1, 1)], + &[(2, 2)], +]; + +/// Frame sizes: whole blocks, whole MCUs, and sizes that end partway +/// through a block or an MCU, where non-interleaved scans visit fewer +/// blocks than the MCU grid holds. +const SIZES: [(u16, u16); 6] = [(8, 8), (1, 1), (20, 12), (33, 17), (16, 16), (47, 9)]; + +#[test] +fn progressive_ac_coefficients_land_at_their_zigzag_index() { + // AC energy at zig-zag 1, 2, and 5: natural (0,1), (1,0), (0,2). + // A coefficient mapped through the zig-zag table twice lands at + // the wrong frequency, so the block no longer matches baseline. + let mut block = [0i16; 64]; + block[0] = 40; + block[1] = 30; + block[2] = -20; + block[5] = 12; + block[63] = 3; + let img = Image { + width: 8, + height: 8, + sampling: vec![(1, 1)], + blocks: vec![vec![block]], + }; + let baseline = decode_jpeg(&encode_baseline(&img)).unwrap(); + let progressive = decode_jpeg(&encode_progressive(&img, &spectral_script(1))).unwrap(); + assert_same_pixels(&progressive, &baseline, "one block"); + // The block is not flat, so the check has teeth. + assert_ne!(baseline.get(0, 0), baseline.get(7, 0)); +} + +#[test] +fn progressive_spectral_selection_matches_baseline() { + // The same coefficients coded as one baseline scan and as a + // spectral-selection progressive script decode to the same pixels + // for every layout and size. + for (li, layout) in LAYOUTS.iter().enumerate() { + for (si, &(w, h)) in SIZES.iter().enumerate() { + let seed = 0x9E37_79B9 ^ ((li as u32) << 8) ^ si as u32; + let img = Image::random(w, h, layout, seed, 40, 3); + let baseline = decode_jpeg(&encode_baseline(&img)).unwrap(); + let script = spectral_script(layout.len()); + let progressive = decode_jpeg(&encode_progressive(&img, &script)).unwrap(); + assert_eq!( + (progressive.width, progressive.height), + (u32::from(w), u32::from(h)) + ); + assert_same_pixels(&progressive, &baseline, &format!("{layout:?} at {w}x{h}")); + } + } +} + +#[test] +fn progressive_eob_runs_and_zero_runs_match_baseline() { + // Sparse blocks: most AC bands are empty, so the AC scans code + // long EOB runs across blocks, and the few nonzero values sit + // behind runs of more than 16 zeros (ZRL). + for layout in [LAYOUTS[0], LAYOUTS[3]] { + let img = Image::random(64, 48, layout, 0x0BAD_5EED, 9, 40); + let baseline = decode_jpeg(&encode_baseline(&img)).unwrap(); + let script = spectral_script(layout.len()); + let progressive = decode_jpeg(&encode_progressive(&img, &script)).unwrap(); + assert_same_pixels(&progressive, &baseline, &format!("{layout:?}")); + } +} + +#[test] +fn progressive_single_component_dc_scans_visit_only_the_sample_blocks() { + // 4:2:0 at 20x12: the luma MCU grid is 4x2 blocks, but its samples + // fill only 3x2. A luma-only DC scan codes 6 blocks; reading 8 + // would take the chroma scans' bits. + let img = Image::random(20, 12, LAYOUTS[3], 7, 30, 4); + let mut script = vec![scan(vec![0], 0, 0, 0, 0), scan(vec![1, 2], 0, 0, 0, 0)]; + script.extend(spectral_script(3).into_iter().skip(1)); + let baseline = decode_jpeg(&encode_baseline(&img)).unwrap(); + let progressive = decode_jpeg(&encode_progressive(&img, &script)).unwrap(); + assert_same_pixels(&progressive, &baseline, "luma-only DC scan"); +} + +#[test] +fn one_component_frames_ignore_their_sampling_factors() { + // A one-component scan is non-interleaved, so a grayscale frame + // that declares 2x2 sampling codes its blocks in the same order as + // one that declares 1x1. Both modes must read them that way. + let (w, h) = (20u16, 12u16); + let plain = Image::random(w, h, &[(1, 1)], 0x51DE, 40, 3); + let mut wide = Image::random(w, h, &[(2, 2)], 1, 0, 1); + // Copy each visible block to the same position of the 2x2 grid: + // 3x2 blocks of samples, 3 blocks per row in the 1x1 grid, 4 in + // the 2x2 grid. + for by in 0..2 { + for bx in 0..3 { + wide.blocks[0][by * 4 + bx] = plain.blocks[0][by * 3 + bx]; + } + } + let expected = decode_jpeg(&encode_baseline(&plain)).unwrap(); + let baseline = decode_jpeg(&encode_baseline(&wide)).unwrap(); + assert_same_pixels(&baseline, &expected, "baseline"); + let progressive = decode_jpeg(&encode_progressive(&wide, &spectral_script(1))).unwrap(); + assert_same_pixels(&progressive, &expected, "progressive"); +} + +/// Panics at the first byte where two decodes differ. +#[track_caller] +pub(super) fn assert_same_pixels(got: &ColorPixmap, want: &ColorPixmap, what: &str) { + assert_eq!((got.width, got.height), (want.width, want.height), "{what}"); + if let Some(i) = got.data.iter().zip(&want.data).position(|(a, b)| a != b) { + panic!( + "{what}: byte {i} is {} instead of {}", + got.data[i], want.data[i] + ); + } +} From de24a76d649fef67f16e4a17561a2faa99e6d7eb Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 08:37:21 -0800 Subject: [PATCH 02/13] Decode progressive JPEG AC refinement scans 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. --- .gitattributes | 2 + crates/sigilbuzz-render/src/jpeg_decode.rs | 36 ++- .../src/jpeg_decode/progressive.rs | 156 ++++++++++-- .../src/jpeg_decode/tests/encode.rs | 179 ++++++++++++-- .../tests/fixtures/pillow_gray_47x9_base.jpg | Bin 0 -> 403 bytes .../tests/fixtures/pillow_gray_47x9_prog.jpg | Bin 0 -> 363 bytes .../tests/fixtures/pillow_gray_47x9_prog.raw | 1 + .../fixtures/pillow_rgb420_33x17_base.jpg | Bin 0 -> 776 bytes .../fixtures/pillow_rgb420_33x17_prog.jpg | Bin 0 -> 699 bytes .../fixtures/pillow_rgb422_33x17_base.jpg | Bin 0 -> 1386 bytes .../fixtures/pillow_rgb422_33x17_prog.jpg | Bin 0 -> 1288 bytes .../fixtures/pillow_rgb444_20x12_base.jpg | Bin 0 -> 771 bytes .../fixtures/pillow_rgb444_20x12_prog.jpg | Bin 0 -> 694 bytes .../fixtures/pillow_rgb444_20x12_prog.raw | 1 + .../src/jpeg_decode/tests/progressive.rs | 227 +++++++++++++++++- .../tools/build_progressive_jpeg_fixtures.py | 82 +++++++ 16 files changed, 640 insertions(+), 44 deletions(-) create mode 100644 crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_base.jpg create mode 100644 crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_prog.jpg create mode 100644 crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_prog.raw create mode 100644 crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb420_33x17_base.jpg create mode 100644 crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb420_33x17_prog.jpg create mode 100644 crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb422_33x17_base.jpg create mode 100644 crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb422_33x17_prog.jpg create mode 100644 crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_base.jpg create mode 100644 crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_prog.jpg create mode 100644 crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_prog.raw create mode 100644 tests/tools/build_progressive_jpeg_fixtures.py diff --git a/.gitattributes b/.gitattributes index d3e71ff3..92c3aed5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,5 @@ *.otf binary *.woff binary *.woff2 binary +*.jpg binary +*.raw binary diff --git a/crates/sigilbuzz-render/src/jpeg_decode.rs b/crates/sigilbuzz-render/src/jpeg_decode.rs index beb3b2c0..254cdba5 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode.rs @@ -7,10 +7,11 @@ //! //! - **8-bit precision only.** All real-world font sbix JPEGs are 8-bit. //! - **Baseline sequential DCT (SOF0)** plus **progressive DCT (SOF2)** -//! first-time DC and AC scans, plus DC successive-approximation -//! refinement. AC successive-approximation refinement scans (Ah > 0 -//! on an AC band) are surfaced as [`RenderError::BadJpeg`]. They are -//! uncommon in real-world font payloads and not implemented. No +//! with spectral selection and successive approximation: first DC +//! and AC scans and DC and AC refinement scans (T.81 G.1.2). All the +//! scans of a progressive frame together may visit at most 1024 +//! coefficients per 8x8 block, which a valid scan script never +//! needs; a stream past that is [`RenderError::BadJpeg`]. No //! arithmetic coding (SOF9..15), no hierarchical (SOFE). //! - **YCbCr** (3-component) and **grayscale** (1-component). //! - **Sampling factors:** 4:4:4, 4:2:2, 4:2:0, and any combination @@ -45,9 +46,6 @@ //! //! # Non-goals //! -//! - AC successive-approximation refinement scans (rare; the bit-plane -//! walking over existing nonzeros is the thorny progressive -//! subroutine and not seen in font sbix payloads we tested against). //! - Arithmetic coding, lossless JPEG, JPEG-LS, JPEG 2000 (`'jp2 '`), //! TIFF (`'tiff'`). //! - Color-managed output (ICC profiles), EXIF orientation. @@ -97,6 +95,15 @@ const MAX_HUFF_TABLES: usize = 4; /// Hard cap on quantization table count. Same logic: 4 destinations. const MAX_QT_TABLES: usize = 4; +/// Coefficient visits a progressive frame's scans may make per 8x8 +/// block, summed over every scan: a scan of band `Ss..=Se` costs +/// `Se - Ss + 1` per block it covers. A valid script codes each bit of +/// each coefficient once, which costs at most 64 * 14 = 896 (the first +/// scan plus one refinement per bit, `Al <= 13`), so only a stream +/// that repeats scans runs out. The cap keeps a few bytes per scan +/// from making the decoder walk every block again and again. +const SCAN_WORK_PER_BLOCK: u64 = 1024; + // --------------------------------------------------------------------------- // Public API. // --------------------------------------------------------------------------- @@ -107,9 +114,8 @@ const MAX_QT_TABLES: usize = 4; /// Supports 8-bit JPEGs with YCbCr (3-component) or grayscale /// (1-component) data and sampling factors where each component's /// max h/v is `<= 2`. Both baseline (SOF0) and progressive (SOF2, -/// first-time DC/AC scans plus DC successive-approximation -/// refinement) modes are handled. AC successive-approximation -/// refinement scans, arithmetic coding, 16-bit precision, +/// spectral selection and successive approximation, refinement scans +/// included) modes are handled. Arithmetic coding, 16-bit precision, /// hierarchical mode, restart markers, and JPEG2000 / TIFF return /// [`RenderError::BadJpeg`]. /// @@ -159,6 +165,9 @@ struct Decoder<'a> { /// to `mcus_x * h_sampling` and `mcus_y * v_sampling` for each /// component. blocks_per_comp: Vec<(u32, u32)>, + /// Coefficient visits the progressive scans may still spend. See + /// [`SCAN_WORK_PER_BLOCK`]. + scan_budget: u64, } impl<'a> Decoder<'a> { @@ -175,6 +184,7 @@ impl<'a> Decoder<'a> { progressive: false, coeffs: Vec::new(), blocks_per_comp: Vec::new(), + scan_budget: 0, } } @@ -476,6 +486,12 @@ impl<'a> Decoder<'a> { self.blocks_per_comp.push((bw, bh)); self.coeffs.push(vec![0i16; (bw * bh * 64) as usize]); } + let blocks: u64 = self + .blocks_per_comp + .iter() + .map(|&(bw, bh)| u64::from(bw) * u64::from(bh)) + .sum(); + self.scan_budget = blocks.saturating_mul(SCAN_WORK_PER_BLOCK); } } diff --git a/crates/sigilbuzz-render/src/jpeg_decode/progressive.rs b/crates/sigilbuzz-render/src/jpeg_decode/progressive.rs index df73860d..74c32a27 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode/progressive.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode/progressive.rs @@ -85,21 +85,11 @@ impl<'a> Decoder<'a> { return Err(RenderError::BadJpeg("progressive Ah/Al out of range")); } - // AC successive-approximation refinement (bit-plane walking - // over the existing nonzero coefficients) is not implemented. - // Surface it before any table validation so callers see a - // stable error message regardless of the upstream stream's - // table layout. - if !is_dc && ah != 0 { - return Err(RenderError::BadJpeg( - "progressive AC refinement scans not supported", - )); - } - - // Validate Huffman tables for the scan participants. + // Validate Huffman tables for the scan participants. A DC + // refinement scan reads raw bits and needs no table. for &ci in &scan_indices { let comp = &self.components[ci]; - if is_dc && table_slot(&self.dc_huff, comp.dc_huff).is_none() { + if is_dc && ah == 0 && table_slot(&self.dc_huff, comp.dc_huff).is_none() { return Err(RenderError::BadJpeg("missing DC Huffman table")); } if !is_dc && table_slot(&self.ac_huff, comp.ac_huff).is_none() { @@ -107,6 +97,14 @@ impl<'a> Decoder<'a> { } } + // Charge the whole scan before decoding it. + let band = u64::from(se - ss) + 1; + let cost = self.scan_block_count(&scan_indices).saturating_mul(band); + self.scan_budget = self + .scan_budget + .checked_sub(cost) + .ok_or(RenderError::BadJpeg("progressive scans exceed work budget"))?; + // Hand off to the appropriate scan handler. The bit reader // consumes from `self.cursor`; on completion we advance the // cursor past the entropy bytes it consumed (up to but not @@ -121,7 +119,11 @@ impl<'a> Decoder<'a> { self.scan_dc_refine(&mut br, &scan_indices, al)?; } } else if let &[ci] = scan_indices.as_slice() { - self.scan_ac_first(&mut br, ci, ss, se, al)?; + if ah == 0 { + self.scan_ac_first(&mut br, ci, ss, se, al)?; + } else { + self.scan_ac_refine(&mut br, ci, ss, se, al)?; + } } br.pos }; @@ -244,6 +246,23 @@ impl<'a> Decoder<'a> { (comp_w.div_ceil(8).min(bw), comp_h.div_ceil(8).min(bh)) } + /// Blocks a scan over `scan_indices` visits: the component's sample + /// blocks for a single-component scan, every block of every MCU for + /// an interleaved one. + fn scan_block_count(&self, scan_indices: &[usize]) -> u64 { + if let &[ci] = scan_indices { + let (cols, rows) = self.scan_blocks(ci); + return u64::from(cols) * u64::from(rows); + } + let (mcus_x, mcus_y) = self.mcu_grid(); + let per_mcu: u64 = scan_indices + .iter() + .filter_map(|&ci| self.components.get(ci)) + .map(|c| u64::from(c.h_sampling) * u64::from(c.v_sampling)) + .sum(); + u64::from(mcus_x) * u64::from(mcus_y) * per_mcu + } + /// Refinement DC scan (Ah > 0). Reads one bit per block and ORs /// it into bit position `al` of the existing DC coefficient. fn scan_dc_refine( @@ -356,6 +375,105 @@ impl<'a> Decoder<'a> { Ok(()) } + /// AC successive-approximation refinement scan (Ah > 0), T.81 + /// G.1.2.3. Each newly nonzero coefficient arrives as a run/size + /// symbol of size 1 and a sign bit, and becomes `±2^al`. Every + /// coefficient that was already nonzero gets one correction bit, + /// read as the scan walks past it, that adds `2^al` to its + /// magnitude. An EOB run ends the band for a number of blocks, but + /// the nonzero coefficients in the rest of each of those blocks + /// still read their correction bits. + /// + /// Each pass through a loop moves `k` forward or ends the block, + /// and an EOB run counts down once per block, so a scan does at + /// most one step per coefficient of its band per block whatever + /// the stream says. + fn scan_ac_refine( + &mut self, + br: &mut BitReader<'_>, + ci: usize, + ss: u8, + se: u8, + al: u8, + ) -> Result<(), RenderError> { + let comp = self.components[ci]; + let (cols, rows) = self.scan_blocks(ci); + let stride = self.blocks_per_comp.get(ci).map_or(0, |&(bw, _)| bw); + let ac_tbl = table_slot(&self.ac_huff, comp.ac_huff) + .ok_or(RenderError::BadJpeg("missing AC Huffman table"))?; + let Some(coeffs) = self.coeffs.get_mut(ci) else { + return Ok(()); + }; + // `al <= 13`, so both fit an i16. + let p1: i16 = 1 << al; + let m1: i16 = -1 << al; + let (ss, se) = (usize::from(ss), usize::from(se)); + let mut eob_run: u32 = 0; + for by in 0..rows { + for bx in 0..cols { + let off = (by * stride + bx) as usize * 64; + let Some(block) = coeffs.get_mut(off..off + 64) else { + continue; + }; + let mut k = ss; + if eob_run == 0 { + while k <= se { + let rs = br.decode_huff(ac_tbl)?; + let mut run = i32::from(rs >> 4); + let size = rs & 0x0F; + let mut value = 0i16; + if size != 0 { + // The size is 1 in a valid stream; libjpeg + // reads any other size the same way. + value = if br.read_bits(1) != 0 { p1 } else { m1 }; + } else if run != 15 { + // EOBn: this block and 2^run - 1 + the + // extra bits more end here. + eob_run = 1u32 << run; + if run > 0 { + eob_run += br.read_bits(run as u8); + } + break; + } + // Walk past the already-nonzero coefficients, + // refining each, and `run` zero ones. The value + // (or, for ZRL, the 16th zero) goes at the zero + // the walk stops on. + while k <= se { + let coef = &mut block[k]; + if *coef != 0 { + refine_coefficient(br, coef, p1, m1); + } else { + run -= 1; + if run < 0 { + break; + } + } + k += 1; + } + if value != 0 { + if let Some(slot) = block.get_mut(k).filter(|_| k <= se) { + *slot = value; + } + } + k += 1; + } + } + if eob_run > 0 { + // The band ended: only the correction bits of the + // nonzero coefficients left in it follow. + for coef in block.iter_mut().take(se + 1).skip(k) { + if *coef != 0 { + refine_coefficient(br, coef, p1, m1); + } + } + eob_run -= 1; + } + } + } + Ok(()) + } + /// Run IDCT over each component's accumulated coefficient buffer /// and compose into the final RGBA pixmap. pub(super) fn finalize_progressive(&mut self) -> Result { @@ -431,3 +549,13 @@ impl<'a> Decoder<'a> { self.compose_planes(&planes, &plane_strides, max_h, max_v) } } + +/// Reads the correction bit of an already-nonzero coefficient being +/// refined at bit `al` (`p1 = 2^al`, `m1 = -2^al`). A set bit moves the +/// coefficient one step away from zero, unless that bit is already set, +/// as libjpeg does for a stream that refines a bit twice. +fn refine_coefficient(br: &mut BitReader<'_>, coef: &mut i16, p1: i16, m1: i16) { + if br.read_bits(1) != 0 && *coef & p1 == 0 { + *coef = coef.wrapping_add(if *coef >= 0 { p1 } else { m1 }); + } +} diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/encode.rs b/crates/sigilbuzz-render/src/jpeg_decode/tests/encode.rs index 90fbe5fd..77a9faaf 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode/tests/encode.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests/encode.rs @@ -289,27 +289,54 @@ pub(super) fn encode_baseline(img: &Image) -> Vec { /// `img` as a progressive (SOF2) stream under `script`. pub(super) fn encode_progressive(img: &Image, script: &[Scan]) -> Vec { + let parts: Vec<(Scan, Vec)> = script + .iter() + .map(|s| (s.clone(), scan_data(img, s))) + .collect(); + assemble_progressive(img, &parts) +} + +/// A progressive stream for `img` from scan headers and the entropy +/// data that follows each, as given. +pub(super) fn assemble_progressive(img: &Image, scans: &[(Scan, Vec)]) -> Vec { let mut out = header(img, MARKER_SOF2); - for s in script { + for (s, data) in scans { sos(&mut out, &s.comps, s.ss, s.se, s.ah, s.al); - let mut e = Entropy::new(); - let order = img.scan_order(&s.comps); - match (s.ss, s.ah) { - (0, 0) => dc_first(&mut e, img, &order, s.al), - (0, _) => { - for &(ci, b) in &order { - e.bits((img.blocks[ci][b][0] >> s.al) as u32 & 1, 1); - } - } - (_, 0) => ac_first(&mut e, img, &order, s), - _ => panic!("AC refinement scans are not encoded yet"), - } - out.extend_from_slice(&e.finish()); + out.extend_from_slice(data); } out.extend_from_slice(&[0xFF, MARKER_EOI]); out } +/// The entropy-coded data of scan `s` over `img`. +pub(super) fn scan_data(img: &Image, s: &Scan) -> Vec { + let mut e = Entropy::new(); + let order = img.scan_order(&s.comps); + match (s.ss, s.ah) { + (0, 0) => dc_first(&mut e, img, &order, s.al), + (0, _) => { + for &(ci, b) in &order { + e.bits((img.blocks[ci][b][0] >> s.al) as u32 & 1, 1); + } + } + (_, 0) => ac_first(&mut e, img, &order, s), + _ => ac_refine(&mut e, img, &order, s), + } + e.finish() +} + +/// Entropy data that codes `symbols` in order, each `(symbol, bits, +/// bit count)` with the table every test stream carries, for streams +/// no encoder would write. +pub(super) fn raw_data(symbols: &[(u8, u32, u8)]) -> Vec { + let mut e = Entropy::new(); + for &(sym, bits, n) in symbols { + e.symbol(sym); + e.bits(bits, n); + } + e.finish() +} + fn dc_first(e: &mut Entropy, img: &Image, order: &[(usize, usize)], al: u8) { let mut pred = vec![0i32; img.sampling.len()]; for &(ci, b) in order { @@ -387,3 +414,127 @@ pub(super) fn spectral_script(n_comps: usize) -> Vec { } script } + +/// Correction bits libjpeg buffers before it forces out an EOB run. +const MAX_CORR_BITS: usize = 1000; + +/// An AC refinement scan, as libjpeg's `encode_mcu_AC_refine` writes +/// it: newly nonzero coefficients as run/size-1 symbols with a sign +/// bit, correction bits for the already-nonzero ones after the symbol +/// that follows them, and EOB runs that carry the correction bits of +/// the blocks they end. +fn ac_refine(e: &mut Entropy, img: &Image, order: &[(usize, usize)], s: &Scan) { + let (ss, se) = (usize::from(s.ss), usize::from(s.se)); + let mut eob_run = 0u32; + // Correction bits of the blocks in the pending EOB run. + let mut be: Vec = Vec::new(); + for &(ci, b) in order { + let block = &img.blocks[ci][b]; + let abs: Vec = (0..64).map(|k| block[k].unsigned_abs() >> s.al).collect(); + // The last coefficient that becomes nonzero in this scan. + let eob = (ss..=se).rev().find(|&k| abs[k] == 1).unwrap_or(0); + let mut run = 0u8; + // Correction bits of this block since the last symbol. + let mut br: Vec = Vec::new(); + for k in ss..=se { + let a = abs[k]; + if a == 0 { + run += 1; + continue; + } + while run > 15 && k <= eob { + flush_eob_run(e, &mut eob_run, &mut be); + e.symbol(0xF0); + run -= 16; + for bit in br.drain(..) { + e.bits(u32::from(bit), 1); + } + } + if a > 1 { + br.push((a & 1) as u8); + continue; + } + flush_eob_run(e, &mut eob_run, &mut be); + e.symbol((run << 4) | 1); + e.bits(u32::from(block[k] >= 0), 1); + for bit in br.drain(..) { + e.bits(u32::from(bit), 1); + } + run = 0; + } + if run > 0 || !br.is_empty() { + eob_run += 1; + be.append(&mut br); + if eob_run == 0x7FFF || be.len() > MAX_CORR_BITS - 64 + 1 { + flush_eob_run(e, &mut eob_run, &mut be); + } + } + } + flush_eob_run(e, &mut eob_run, &mut be); +} + +/// [`emit_eob_run`], then the correction bits the run carries. +fn flush_eob_run(e: &mut Entropy, eob_run: &mut u32, be: &mut Vec) { + if *eob_run == 0 { + return; + } + emit_eob_run(e, eob_run); + for bit in be.drain(..) { + e.bits(u32::from(bit), 1); + } +} + +/// libjpeg's `jpeg_simple_progression` script: spectral selection and +/// successive approximation, with refinement scans for DC and AC. +pub(super) fn libjpeg_script(n_comps: usize) -> Vec { + let all: Vec = (0..n_comps).collect(); + if n_comps == 3 { + vec![ + scan(all.clone(), 0, 0, 0, 1), + scan(vec![0], 1, 5, 0, 2), + scan(vec![2], 1, 63, 0, 1), + scan(vec![1], 1, 63, 0, 1), + scan(vec![0], 6, 63, 0, 2), + scan(vec![0], 1, 63, 2, 1), + scan(all, 0, 0, 1, 0), + scan(vec![2], 1, 63, 1, 0), + scan(vec![1], 1, 63, 1, 0), + scan(vec![0], 1, 63, 1, 0), + ] + } else { + let mut script = vec![scan(all.clone(), 0, 0, 0, 1)]; + for ci in 0..n_comps { + script.push(scan(vec![ci], 1, 5, 0, 2)); + script.push(scan(vec![ci], 6, 63, 0, 2)); + script.push(scan(vec![ci], 1, 63, 2, 1)); + } + script.push(scan(all, 0, 0, 1, 0)); + for ci in 0..n_comps { + script.push(scan(vec![ci], 1, 63, 1, 0)); + } + script + } +} + +/// Every bit of every coefficient in its own pass: DC and four AC +/// bands, each first coded at `Al = 5` and then refined one bit at a +/// time. Long EOB runs and many correction bits per block. +pub(super) fn deep_script(n_comps: usize) -> Vec { + let all: Vec = (0..n_comps).collect(); + let bands = [(1, 2), (3, 9), (10, 40), (41, 63)]; + let mut script = vec![scan(all.clone(), 0, 0, 0, 5)]; + for ci in 0..n_comps { + for &(ss, se) in &bands { + script.push(scan(vec![ci], ss, se, 0, 5)); + } + } + for al in (0..5).rev() { + script.push(scan(all.clone(), 0, 0, al + 1, al)); + for ci in 0..n_comps { + for &(ss, se) in &bands { + script.push(scan(vec![ci], ss, se, al + 1, al)); + } + } + } + script +} diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_base.jpg b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_base.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9988518fc71e4653fe64dee7a1b16827d4c2e228 GIT binary patch literal 403 zcmex=``2_j6xdp@o1cgOJMMZh|#U;c`H|q zMvW5}awt1(JSZA;@q>zSQc)8pmzcPOq?D?fx`w8fiK&^ng{76Vi>sTvho@I?NN8Ai zL}XNQN@`kqMrKxVNoiSmMP*fUOKV$uM`zch$y26In?7UatVN5LEM2yI#mZHiHgDOw zZTpU$yAB;ba`f2o6DLnyx_ss8wd*%--g@}x@sp>|p1*kc>f@)+U%r0({^RGb|F;-8 zK*46u@J&{Of7)(Y-SDSp^G?ddxw#ofNKIX}b``2_j6xdp@o1cgOJMMZh|#U;cFYFfs`$IYKosvNCC`J@eAy zXJk^}k4>lRfm$R4P&EMkA|Pl`2-U!5&op)O45--*Ow1@wVH99cbTkZvnQgClgweyT z(m&j6MzD-A+=&9q`hnI+3jm!3v``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivX!XqN1l2cOC(lau%ic3n%$}1|Xnp;}i+B-VC zCQY6)b=ve9GiNPYykzOJeA&aSFc^aar4&0 zM~|O8efIpt%U2&ieg5+G+xH(oe}VkP$iNKo7LbH^49#DHKz}i@urRZ*gZ#zFR1U<< zf-J0xhHOHPf$WKe!b(Ps93oB=7j8VrscandK{To8BA1wo$wSqTAg_UaMx4i*$nqK7 zV+eoUV&GwB1V$dSAcH-__rH2~WC~+t_sx>CX^eR`y+B;%x_ACcj>pHce`E`vujRk; zFxfb8GnaJNldT>OcjG$CA91H#oOk6&R2c8%#-7Os|5*H4eeSDge&y?Bs<2WhQ{UMz|r9zCsZ7AqTXzM0Fs`M&Rc-;1~6Pe7eky9I~< z$OqybXo9dTbGam6S*7_rcO&i$wax_vcn%{F@kL=5H=PIk!XYIA^ z<;+v=C#&Ju0JL6?!8u^YnJ8(CCJEFM6_bSqo%{VO3@mA0iA+1g2z2c<&EHMHCJo&_ z^kNQ9eB9~&>c3goWY!F?ndQf5I;zpq^}jx;d8_g|d(vsh*45tr-Ok|5^ Kw6!~mw|@gDLxTnY literal 0 HcmV?d00001 diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb422_33x17_base.jpg b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb422_33x17_base.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a48295b183da548bf23a1c06d04dc5a6d08c0b10 GIT binary patch literal 1386 zcmbV}X;4#F6vyw&OTv<{BqW4d1=$o(&;SC~4jDn#(N;jVfJ52D2owp55erd3RG1+J z6a)(?P@rNR3_Dl`Y*^D)K@mb!5JXVGk`NZbkk^NH+8IB!{hxF0mwV2g|LnEj3F-ubh9+5CLzARQAZY7q zlXNIlDwU{3)6=EskttNl#v>4n&R}tBIGh?qgP=kA&q6u?NfnTQAq*J;3<-is5Yh<@ zP@g#Hy})0BFsMhoimIAA0c~i|1{es2F<5xRHQJqw-UBQNM>eGI$Ln~7su;yntS{u0 zsv0{9?oqu5q$W0D>{K;%U7Fq}n@qQDH#4`e-D79(u=g|P11_#^?jD{#M|}PK0|J@h z5s}BDj*^aCo0?l% ze-U*S{;xa1FZP7X`JuT!^ih6(BMHbSmnU@w)Y4pj^!l2EX#Zvbv#kpz;C+qns z$IXJ4jQLJNcofXq?XExP@#%pGXYG9}Rztnru8&Hty^IXx4K>@tc^=H$=I>;))Zfqb z#ZiwHkA2a0u$iAxCDeP0$G@pgSk6WO#k{2{DQiyhz%Q4T5V$>3UZAkxQ4!Ez_Y?Oy zwq0?EI)Q+%1`xnUtD|6*@4Mcf&+R|RkUAis@C{#A;l`5jJkt(HW2XHFzi$od+bZsl zz3AKD_}YorW<-Qbl1@g88Bz0@MfSp)3>u%P@L&(67`8Swr|zKZxRa-nr}kK7`fKW2 zoeAaKcMPMCZ^j$byZriQdsePazu6nabO{L>eHI%%c9d5z{!L7CQln z536d&ttycuF^ip8>olrx5xk{U%(fnDXE)NA;!HEs^sMWmt#S5KmmTh#dOexfXE%(G z;`?hBwXW215O5@IPD#q0nUI|(&x~ZAoY=N7Sm+pT6yIe0`GvHANWnELdgs{N0DhwW z*CM;7L)@aC5e~1@;UEI+y27M3)1`j=+6vPK&;0t^6%VIY;&!+!$hV9BSpQW?DdrWgSac#0weMUK2+ZWsZkw8IFv6T@G#vv?-k zrT8wnX+6Cj0qgipiW}(&n7q@&9j;m}U-~&tku6{>jVt4KDa9dNp~3PJ0_vl=os5Op u#<6AMBLpm(MNhN1y>d$g)U>}lD;INDDUpn&6$HdZJy`T#_FBt9M*ajT6I)jR literal 0 HcmV?d00001 diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb422_33x17_prog.jpg b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb422_33x17_prog.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c4b0071b9ecd7ae4cc9cd028a7adc140e49aac47 GIT binary patch literal 1288 zcmb7Dc}!Dh0RO&sl+ug$O2Go^Xjuq@;3(jNOa~P#!XTt&fFt4P#1(OyT+`>;0a&-#iMa z2wAucKnMUt4=@jc5P)Ntx7O1Az$Diu#i9;-62R@lC%|Y*<8W@hPe&UOh5w>4n+W?Ax=Yc2XF!a!;#N5 zVBn+;fsTX_;FcV(fRPMzau;yW4UEDmlrj8dvFv%{v21No{m=Pcw87Y2J>k@qcOqww zeM&W9j6poFQ2;|&qe74v4j2w^L7KlA(J_$2=9_6PTe9)}B8Bh@YcY0m^wXZgX%bw`&McmZh7&^I>t;yc=>m8r0)Y-7eT2lldX}(CVpwO2UOMLyaHcOT2(n`JIg(uA7>1=@8xvLGM-Wv~w#5)c6 za=J9tX^Hy}n=0&uxgC|{dd~wYZq+yGTSXUbRV<~VbzvnkefQ`*^Y~obiciw#jq$_L zNd8RnKQe|egfu#KWed>5fe~30`vN z_LPSg5(LTEg(1!scimp5ZtjCOjAK|Li}k%859N6l$EO}rwY0+@{LW{1&iD>Ion%jU zpLpNrTrHt!`Zlq0ZX~uiH=ovI8qYY*@3?d%B1x+@et76!-*WWK`UmCxg|jY?`6KaZ z(NQhKmdY#xfyCOaK{Qi|w3L!hD-Ejy2@+)vIIXZt6>T z+4-rayYSO(ed9@H5L#Q{5?Lr>?37i**!xkfvM!Gey|=y)`_{G&rFKht+O{^-JX>y9 zFatzwoie~tS=2_cx;_Zmai!57F^H3^4FanjN;-}9q<`VZvLfoo?L6IHX$1`aGxEyu zx|!2qRc{A}1S=;;nmoe2B}|j7v`E3&XB^b*yu%y!=EgfeyYky-EzirX8kWfLl?2id z0f|(prz%a;Ppp|OtDsnvhsGx*f8gQb`J(*>_jQC+<``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivX!XqN1l2cOC(lau%ic3n%$}1|Xnp;}i+B-VC zCQY6)b=ve9GiNPYykzOJeA&aSFc^aar4&0 zM~|O8efIpt%U2&ieg5+G+xH(oe}VkP$iNKo7LbH^49#D_KwxBIVPR%r2lxYE z#}NLy#lXYN2#h>tK?ZwewDW6<-Jz()0(jvfA8wnrGM0&y7jB^l!uZU=Bq8eJC4uF*4WFDwzucZDue8{ zC4s9_zL#(Q$tyqOkWo>9a8up<*3yf|k}@AYZE20v3MwxQs#*JTb?=*d-O(knEsthD U%MPBiqxjjZYue6g_5W`I09XMDT>t<8 literal 0 HcmV?d00001 diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_prog.jpg b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_prog.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3e955cf05c1dc2aa2c0e6dd88686a0b377011f8b GIT binary patch literal 694 zcmb7=Pe>I}6vofFcjmo$^RIiJ^Zw;R<}FlQh;JcCi>8P0#AsuX8!bp(2(3bJVT%w~ ziY{8cMYM>P5+WFdh=PhlC6Az@46-Ot6msK!Ix~`t?K=0a?)}bpe%}c{hu=Y6ul3ge z5dir>_!W9V3K3l{jjwL#x~A!7I-N3X)3zlx3n9Phs?+ypYf22Ub^Ae4mRHWUCPV_K`=rp9qxh6 z9YRUP5kK_QJ`HcR=MTqLTc2@PvjPs9G(-WC{Bni+47NmFJRkk|)_FRA;GA$nk?>-6^Q@kv*WI+yQt zr>?dwOde&}<#NoIOr;1=_-6wVQh}(~&8ZfamQ+M`AqtSM?*0uD_^sW&J!@|EcZ2%) zG2RM9e2RW)0nr}Jb=?HqT#zO(R{rOnY6F6;q$R;4;{|2&u)8KRZq>EcV* z++Q9uXm@;YV(8|N7w> (Image, Vec<(Scan, Vec)>) { + let mut img = Image::random(8, 16, &[(1, 1)], 3, 0, 1); + img.blocks[0][0][0] = 24; + img.blocks[0][1][0] = -40; + let parts = [scan(vec![0], 0, 0, 0, 0), scan(vec![0], 1, 63, 0, 1)] + .into_iter() + .map(|s| { + let data = scan_data(&img, &s); + (s, data) + }) + .collect(); + (img, parts) +} + +#[test] +fn refinement_eob_run_past_the_last_block_ends_with_the_scan() { + // EOB14 with every extra bit set ends 32,767 blocks, far more than + // the frame has. The run stops at the end of the scan, and the + // next scan still decodes. + let (img, parts) = flat_two_blocks(); + let expected = decode_jpeg(&assemble_progressive(&img, &parts)).unwrap(); + let mut long = parts.clone(); + let refine = scan(vec![0], 1, 63, 1, 0); + long.push((refine.clone(), raw_data(&[(0xE0, 0x3FFF, 14)]))); + long.push((refine, raw_data(&[(0x00, 0, 0)]))); + let got = decode_jpeg(&assemble_progressive(&img, &long)).unwrap(); + assert_same_pixels(&got, &expected, "EOB run past the frame"); +} + +#[test] +fn refinement_value_past_the_band_is_dropped() { + // Three ZRLs and a run of 15 ask for a new coefficient 64 zeros + // in, past coefficient 63. The walk stops at the band's end and + // the value is dropped, so the block keeps its first-pass value. + let (img, parts) = flat_two_blocks(); + let expected = decode_jpeg(&assemble_progressive(&img, &parts)).unwrap(); + let mut over = parts.clone(); + let data = raw_data(&[ + (0xF0, 0, 0), + (0xF0, 0, 0), + (0xF0, 0, 0), + (0xF1, 1, 1), + (0x00, 0, 0), + ]); + over.push((scan(vec![0], 1, 63, 1, 0), data)); + let got = decode_jpeg(&assemble_progressive(&img, &over)).unwrap(); + assert_same_pixels(&got, &expected, "value past the band"); +} + +#[test] +fn refinement_scan_cut_short_reads_zero_bits() { + // A refinement scan whose data stops after one symbol reads zero + // bits for the rest, like libjpeg, instead of failing or running + // on. + let img = Image::random(32, 32, &[(1, 1)], 11, 60, 3); + let script = libjpeg_script(1); + let mut parts: Vec<(Scan, Vec)> = script + .iter() + .map(|s| (s.clone(), scan_data(&img, s))) + .collect(); + let (_, last) = parts.last_mut().unwrap(); + last.truncate(1); + let pix = decode_jpeg(&assemble_progressive(&img, &parts)).unwrap(); + assert_eq!((pix.width, pix.height), (32, 32)); +} + +#[test] +fn repeated_scans_run_out_the_work_budget() { + // Each scan costs its band width per block it covers. A frame may + // spend 1024 coefficient visits per block, and every pass over AC + // 1..=63 costs 63 per block, so a stream that keeps repeating the last refinement scan stops + // with an error instead of walking the frame again and again. + let (img, mut parts) = flat_two_blocks(); + let refine = scan(vec![0], 1, 63, 1, 0); + for _ in 0..40 { + parts.push((refine.clone(), raw_data(&[(0x10, 1, 1)]))); + } + assert_eq!( + decode_jpeg(&assemble_progressive(&img, &parts)).unwrap_err(), + RenderError::BadJpeg("progressive scans exceed work budget") + ); +} + +#[test] +fn dc_refinement_scans_need_no_dc_table() { + // A DC refinement scan reads one raw bit per block, so its DC + // table selector may name an empty slot, as libjpeg allows. + let img = Image::random(16, 16, LAYOUTS[1], 5, 50, 3); + let baseline = decode_jpeg(&encode_baseline(&img)).unwrap(); + let mut bytes = encode_progressive(&img, &libjpeg_script(3)); + // The seventh scan is the interleaved DC refinement. Point its + // three DC selectors at slot 3, which holds no table. + let sos = bytes + .windows(2) + .enumerate() + .filter(|(_, w)| *w == [0xFF, MARKER_SOS]) + .map(|(i, _)| i) + .nth(6) + .unwrap(); + for c in 0..3 { + bytes[sos + 6 + 2 * c] = 0x30; + } + let progressive = decode_jpeg(&bytes).unwrap(); + assert_same_pixels(&progressive, &baseline, "DC refinement with table 3"); +} + +/// One Pillow (libjpeg-turbo) fixture: a progressive file written with +/// libjpeg's default script, its baseline twin with the same quantized +/// coefficients, and for layouts without chroma subsampling Pillow's +/// own decode of the progressive file as raw RGB or gray samples. +struct PillowCase { + name: &'static str, + progressive: &'static [u8], + baseline: &'static [u8], + pillow: Option<&'static [u8]>, +} + +/// Built by `tests/tools/build_progressive_jpeg_fixtures.py`. +const PILLOW: [PillowCase; 4] = [ + PillowCase { + name: "4:4:4 20x12", + progressive: include_bytes!("fixtures/pillow_rgb444_20x12_prog.jpg"), + baseline: include_bytes!("fixtures/pillow_rgb444_20x12_base.jpg"), + pillow: Some(include_bytes!("fixtures/pillow_rgb444_20x12_prog.raw")), + }, + PillowCase { + name: "4:2:2 33x17", + progressive: include_bytes!("fixtures/pillow_rgb422_33x17_prog.jpg"), + baseline: include_bytes!("fixtures/pillow_rgb422_33x17_base.jpg"), + pillow: None, + }, + PillowCase { + name: "4:2:0 33x17", + progressive: include_bytes!("fixtures/pillow_rgb420_33x17_prog.jpg"), + baseline: include_bytes!("fixtures/pillow_rgb420_33x17_base.jpg"), + pillow: None, + }, + PillowCase { + name: "gray 47x9", + progressive: include_bytes!("fixtures/pillow_gray_47x9_prog.jpg"), + baseline: include_bytes!("fixtures/pillow_gray_47x9_base.jpg"), + pillow: Some(include_bytes!("fixtures/pillow_gray_47x9_prog.raw")), + }, +]; + +#[test] +fn pillow_progressive_files_decode_like_their_baseline_twins() { + // libjpeg-turbo's progressive files: a DHT before each scan, DC + // and AC refinement, and frames that end partway through an MCU. + for case in &PILLOW { + let progressive = decode_jpeg(case.progressive).unwrap(); + let baseline = decode_jpeg(case.baseline).unwrap(); + assert_same_pixels(&progressive, &baseline, case.name); + let Some(pillow) = case.pillow else { + continue; + }; + // Pillow's integer IDCT and color conversion land a few levels + // from the float IDCT here. + let pixels = (progressive.width * progressive.height) as usize; + let channels = pillow.len() / pixels; + let mut max = 0; + for (i, px) in progressive.data.chunks_exact(4).enumerate() { + for (c, &v) in px.iter().take(3).enumerate() { + max = max.max(v.abs_diff(pillow[i * channels + c.min(channels - 1)])); + } + } + assert!(max <= 4, "{}: {max} levels from Pillow", case.name); + } +} diff --git a/tests/tools/build_progressive_jpeg_fixtures.py b/tests/tools/build_progressive_jpeg_fixtures.py new file mode 100644 index 00000000..a3744ddd --- /dev/null +++ b/tests/tools/build_progressive_jpeg_fixtures.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Write the progressive JPEG fixtures of sigilbuzz-render's decoder +tests with Pillow (libjpeg-turbo). + +Each picture is saved twice with the same quality and subsampling, as a +progressive file (libjpeg's default script, with DC and AC refinement +scans) and as a baseline file, so both carry the same quantized +coefficients and must decode to the same pixels. For the layouts +without chroma subsampling, Pillow's own decode is saved next to them as +raw RGB or gray samples; the tests allow the few levels that integer +and float IDCTs differ by. + +Run: + uv run --with pillow python tests/tools/build_progressive_jpeg_fixtures.py +""" + +from __future__ import annotations + +import math +from pathlib import Path + +from PIL import Image + +OUT = ( + Path(__file__).resolve().parent.parent.parent + / "crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures" +) + + +def emoji(w: int, h: int) -> Image.Image: + img = Image.new("RGB", (w, h)) + px = img.load() + for y in range(h): + for x in range(w): + d = math.hypot(x - w * 0.4, y - h * 0.45) + r = max(0, min(255, int(250 - d * 3))) + g = max(0, min(255, int(210 - d * 2.5))) + inside = (x - w * 0.6) ** 2 + (y - h * 0.6) ** 2 <= (min(w, h) * 0.15) ** 2 + px[x, y] = (r, g, 240 if inside else 30) + return img + + +def shapes(w: int, h: int) -> Image.Image: + img = Image.new("RGB", (w, h)) + px = img.load() + for y in range(h): + for x in range(w): + d = math.hypot(x - w / 2, y - h / 2) + r = 230 if d < min(w, h) / 3 else 20 + g = 200 if (x // 5 + y // 7) % 2 else 40 + b = int(128 + 127 * math.sin(x / 3.0) * math.cos(y / 4.0)) + px[x, y] = (r, g, b) + return img + + +# (name, picture, Pillow mode, subsampling, keep Pillow's decode) +CASES = [ + ("rgb444_20x12", emoji(20, 12), "RGB", 0, True), + ("rgb422_33x17", shapes(33, 17), "RGB", 1, False), + ("rgb420_33x17", emoji(33, 17), "RGB", 2, False), + ("gray_47x9", emoji(47, 9), "L", None, True), +] + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + for name, picture, mode, subsampling, keep in CASES: + img = picture.convert(mode) + kw = {"quality": 90} + if subsampling is not None: + kw["subsampling"] = subsampling + for kind, progressive in [("prog", True), ("base", False)]: + path = OUT / f"pillow_{name}_{kind}.jpg" + img.save(path, progressive=progressive, **kw) + if keep: + decoded = Image.open(OUT / f"pillow_{name}_prog.jpg") + decoded.load() + (OUT / f"pillow_{name}_prog.raw").write_bytes(decoded.tobytes()) + + +if __name__ == "__main__": + main() From e4a85a7ce1da9125858071eda6b0e78fd32821be Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 08:53:00 -0800 Subject: [PATCH 03/13] Round COLRv1 paint and VARC axis coords to F2DOT14 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. --- crates/sigilbuzz-paint/src/deltas.rs | 82 +++++++++++++++++-- crates/sigilbuzz-paint/src/eval.rs | 3 +- crates/sigilbuzz-paint/src/options.rs | 5 +- crates/sigilbuzz-paint/src/walk.rs | 4 +- .../sigilbuzz-paint/tests/variable_units.rs | 21 +++++ src/tables/varc.rs | 29 ++++--- src/tables/varc/tests.rs | 74 +++++++++++++++++ 7 files changed, 195 insertions(+), 23 deletions(-) diff --git a/crates/sigilbuzz-paint/src/deltas.rs b/crates/sigilbuzz-paint/src/deltas.rs index d4fdae08..bcf77468 100644 --- a/crates/sigilbuzz-paint/src/deltas.rs +++ b/crates/sigilbuzz-paint/src/deltas.rs @@ -13,13 +13,21 @@ //! bits, `inner` in the low 16 bits. //! //! No other table is consulted; GDEF's variation store belongs to GDEF -//! and GPOS. The sentinel `0xFFFFFFFF` and an empty coordinate slice -//! both mean "no delta". +//! and GPOS. The sentinel `0xFFFFFFFF` means "no delta". +//! +//! The coordinates are rounded to F2DOT14 first, multiples of 1/16384 +//! with halves rounded up, the precision HarfBuzz stores a font's +//! coordinates in. Shaping and `Face::glyph_outline_at_coords` round +//! them the same way, so a paint and the outlines it fills vary at the +//! same instance. Coordinates that all round to zero, or none at all, +//! are the default instance and mean "no delta". //! //! Both [`crate::evaluate_with`] and [`crate::walk`] read deltas through //! the same [`Deltas`] value, so they never disagree about units or index //! mapping. +use alloc::vec::Vec; + use sigilbuzz::tables::colr::{ClipBox, Colr, VarIndexBase}; use sigilbuzz::tables::variation_store::ItemVariationStore; @@ -27,18 +35,19 @@ use sigilbuzz::tables::variation_store::ItemVariationStore; const NO_VARIATION: VarIndexBase = VarIndexBase::MAX; /// Variation deltas for one evaluation: COLR's item variation store, -/// its optional index map, and the normalized coordinates. -pub(crate) struct Deltas<'a, 'c> { +/// its optional index map, and the normalized coordinates rounded to +/// F2DOT14. +pub(crate) struct Deltas<'a> { store: Option>, map: Option>, - coords: &'c [f32], + coords: Vec, } -impl<'a, 'c> Deltas<'a, 'c> { +impl<'a> Deltas<'a> { /// Reads the variation store and index map named by `colr`'s header. /// An offset that does not lead to a well-formed structure is /// treated as absent, which is what HarfBuzz's sanitizer does to it. - pub(crate) fn new(colr: &Colr<'a>, coords: &'c [f32]) -> Self { + pub(crate) fn new(colr: &Colr<'a>, coords: &[f32]) -> Self { let data = colr.data(); let store = colr .var_store_offset() @@ -47,7 +56,11 @@ impl<'a, 'c> Deltas<'a, 'c> { let map = colr .var_index_map_offset() .and_then(|off| DeltaSetIndexMap::parse(data, off as usize)); - Self { store, map, coords } + Self { + store, + map, + coords: f2dot14_coords(coords), + } } /// The raw delta for field `field` of a record whose base index is @@ -62,7 +75,7 @@ impl<'a, 'c> Deltas<'a, 'c> { }; let index = base.wrapping_add(u32::from(field)); let index = self.map.as_ref().map_or(index, |map| map.map(index)); - store.delta((index >> 16) as u16, index as u16, self.coords) + store.delta((index >> 16) as u16, index as u16, &self.coords) } /// Delta for an F2DOT14 field, as a fraction: 8192 ticks is 0.5. @@ -96,6 +109,40 @@ impl<'a, 'c> Deltas<'a, 'c> { } } +/// `coords` rounded to F2DOT14 as HarfBuzz stores a font's coords: each +/// a multiple of 1/16384, rounded halves up (`floor(x * 16384 + 0.5)`), +/// with NaN read as zero. Empty when every coordinate rounds to zero, +/// the default instance. The same rule as the core crate's shaping and +/// outline paths. +fn f2dot14_coords(coords: &[f32]) -> Vec { + let rounded: Vec = coords.iter().map(|&c| round_f2dot14(c)).collect(); + if rounded.iter().all(|&c| c == 0.0) { + return Vec::new(); + } + rounded +} + +/// One coordinate rounded to a multiple of 1/16384, halves up. Written +/// without `f32::floor`, which `core` lacks before Rust 1.85. +fn round_f2dot14(c: f32) -> f32 { + if c.is_nan() { + return 0.0; + } + let x = c * 16384.0 + 0.5; + // Every f32 of magnitude 2^23 or more is a whole number already. + let floor = if (-8_388_608.0..8_388_608.0).contains(&x) { + let t = x as i32 as f32; + if t > x { + t - 1.0 + } else { + t + } + } else { + x + }; + floor / 16384.0 +} + /// A borrowed `DeltaSetIndexMap` (format 0 or 1): /// /// ```text @@ -181,6 +228,23 @@ mod tests { out } + #[test] + fn coords_round_to_f2dot14_halves_up() { + // A 16.16 coord `k / 65536` lands on `((k + 2) >> 2) / 16384`, as + // HarfBuzz rounds it. + for k in -70_000i32..=70_000 { + let got = f2dot14_coords(&[k as f32 / 65536.0, 1.0]); + assert_eq!(got[0], ((k + 2) >> 2) as f32 / 16384.0, "k = {k}"); + } + assert!(f2dot14_coords(&[1.0 / 65536.0, -2.0 / 65536.0]).is_empty()); + assert!(f2dot14_coords(&[f32::NAN, 0.0]).is_empty()); + assert!(f2dot14_coords(&[]).is_empty()); + assert_eq!(f2dot14_coords(&[f32::NAN, 0.5]), [0.0, 0.5]); + // Out-of-range and huge values keep their size. + assert_eq!(f2dot14_coords(&[3.0, -1e30]), [3.0, -1e30]); + assert_eq!(f2dot14_coords(&[f32::INFINITY]), [f32::INFINITY]); + } + #[test] fn map_splits_entries_into_outer_and_inner() { for format in [0, 1] { diff --git a/crates/sigilbuzz-paint/src/eval.rs b/crates/sigilbuzz-paint/src/eval.rs index 613eb48e..eae930ee 100644 --- a/crates/sigilbuzz-paint/src/eval.rs +++ b/crates/sigilbuzz-paint/src/eval.rs @@ -108,7 +108,8 @@ pub fn evaluate(face: &Face<'_>, gid: GlyphId) -> Vec { /// every `PaintVar*` node visited. `coords` is the normalized axis /// vector, the same shape sigilbuzz's /// [`Face::glyph_outline_at_coords`](sigilbuzz::Face::glyph_outline_at_coords) -/// accepts. An empty slice is the static (no-deltas) path. +/// accepts. An empty slice is the static (no-deltas) path. The deltas +/// are read at the coords rounded to F2DOT14, as HarfBuzz reads them. #[must_use] pub fn evaluate_at_coords(face: &Face<'_>, gid: GlyphId, coords: &[f32]) -> Vec { evaluate_with(face, gid, &EvalOptions::new().with_coords(coords)) diff --git a/crates/sigilbuzz-paint/src/options.rs b/crates/sigilbuzz-paint/src/options.rs index bc2a7811..e12e5342 100644 --- a/crates/sigilbuzz-paint/src/options.rs +++ b/crates/sigilbuzz-paint/src/options.rs @@ -87,7 +87,10 @@ impl<'a> EvalOptions<'a> { /// Evaluates `PaintVar*` deltas at `coords`, the normalized axis /// vector (the same shape /// [`Face::glyph_outline_at_coords`](sigilbuzz::Face::glyph_outline_at_coords) - /// accepts). An empty slice is the static path. + /// accepts). An empty slice is the static path. The deltas are read + /// at the coords rounded to F2DOT14 (multiples of 1/16384, halves + /// up), as HarfBuzz stores them and as the outlines are drawn; + /// [`EvalOptions::coords`] returns them as given. /// /// ``` /// use sigilbuzz_paint::EvalOptions; diff --git a/crates/sigilbuzz-paint/src/walk.rs b/crates/sigilbuzz-paint/src/walk.rs index 41f6b94a..d3ab042c 100644 --- a/crates/sigilbuzz-paint/src/walk.rs +++ b/crates/sigilbuzz-paint/src/walk.rs @@ -329,7 +329,7 @@ fn paint( fn root_clip( face: &Face<'_>, colr: &Colr<'_>, - deltas: &Deltas<'_, '_>, + deltas: &Deltas<'_>, glyph: GlyphId, coords: &[f32], ) -> RootClip { @@ -349,7 +349,7 @@ fn root_clip( struct Walker<'a, 'b, 's> { colr: &'b Colr<'a>, - deltas: Deltas<'a, 'b>, + deltas: Deltas<'a>, /// COLR glyphs whose trees are on the walk stack. glyphs: Vec, /// LayerList indices on the walk stack. diff --git a/crates/sigilbuzz-paint/tests/variable_units.rs b/crates/sigilbuzz-paint/tests/variable_units.rs index f1a74d16..1f85ec50 100644 --- a/crates/sigilbuzz-paint/tests/variable_units.rs +++ b/crates/sigilbuzz-paint/tests/variable_units.rs @@ -309,3 +309,24 @@ fn var_skew_angle_delta_is_f2dot14() { assert!(close(t.xy, -1.0), "at +1: {t:?}"); assert!(close(t.yx, 0.0), "at +1: {t:?}"); } + +#[test] +fn deltas_read_coords_rounded_to_f2dot14() { + // HarfBuzz stores a font's coords as F2DOT14 and resolves every + // PaintVar* delta there, so a coord between two F2DOT14 steps + // lands on the nearer one (halves up), as shaping and outlines do. + let bytes = font_bytes(); + let face = Face::parse_bytes(&bytes, 0).expect("face parses"); + let sx = |coord: f32| one_fill(&face, 2, &[coord]).0.xx; + // 0.25 + 1/65536, a 16.16 coord from `Fvar::normalize_coords`, + // rounds to 0.25: the scale is 1 + 0.5 * 0.25 exactly. + assert_eq!(sx(0.25 + 1.0 / 65536.0), 1.125); + assert_eq!(sx(0.25 - 1.0 / 65536.0), 1.125); + // Half a step rounds up. + assert_eq!(sx(1.0 / 32768.0), 1.0 + 0.5 / 16384.0); + assert_eq!(sx(3.0 / 32768.0), 1.0 + 0.5 * 2.0 / 16384.0); + // Less than half a step is the default instance. + assert_eq!(sx(1.0 / 65536.0), 1.0); + // NaN reads as zero. + assert_eq!(sx(f32::NAN), 1.0); +} diff --git a/src/tables/varc.rs b/src/tables/varc.rs index b0ddd45f..85222300 100644 --- a/src/tables/varc.rs +++ b/src/tables/varc.rs @@ -53,7 +53,7 @@ use alloc::vec::Vec; use crate::error::{Error, Result}; use crate::tables::layout::Coverage; use crate::tables::multi_var_store::{read_cff2_index, MultiVarStore}; -use crate::tables::parse::Reader; +use crate::tables::parse::{hb_roundf, Reader}; /// A parsed `VARC` table. #[derive(Debug, Clone)] @@ -82,7 +82,10 @@ pub struct VarcComponent { /// Effective normalized axis coords for the child outline, in /// `fvar` axis order: the parent's coord vector with any HAVE_AXES /// values written over the listed axes. The vector grows to cover - /// the highest listed axis when the parent's is shorter. + /// the highest listed axis when the parent's is shorter. Each + /// written value is the component's axis value plus its deltas, + /// rounded to F2DOT14 (a multiple of 1/16384, halves up) as + /// HarfBuzz stores coords. pub coords: Vec, } @@ -275,30 +278,36 @@ impl<'a> Varc<'a> { context: "VARC component axisIndicesIndex out of range", })?; let n = axis_indices.len(); - // The TupleValues stream packs `n` F2DOT14 values, but - // sigilbuzz's decode_tuple_values yields i32; we treat - // each as F2DOT14 by dividing by 16384. + // The TupleValues stream packs `n` values in F2DOT14 units. + // They stay in those units while their deltas are added. let raw_values = decode_tuple_values_in_reader(r, n).ok_or(Error::Malformed { offset: r.position(), context: "VARC component axisValues TupleValues truncated", })?; - let mut axis_values: Vec = - raw_values.into_iter().map(|v| v as f32 / 16384.0).collect(); + let mut axis_values: Vec = raw_values.into_iter().map(|v| v as f32).collect(); - // Optional per-value variation deltas. + // Optional per-value variation deltas, also in F2DOT14 + // units. HarfBuzz adds none at the default instance. if flags & VC_AXIS_VALUES_HAVE_VARIATION != 0 { let var_idx = read_uint32var(r)?; - if let Some(store) = &self.var_store { + if let Some(store) = self.var_store.as_ref().filter(|_| !coords.is_empty()) { let outer = (var_idx >> 16) as u16; let inner = var_idx & 0xFFFF; if let Some(deltas) = store.resolve_deltas(outer, inner, n, coords) { for (value, d) in axis_values.iter_mut().zip(&deltas) { - *value += d / 16384.0; + *value += d; } } } } + // HarfBuzz keeps coords as whole F2DOT14 values, so each + // value plus its deltas rounds to one, halves up, before it + // becomes the child's coord. + for value in &mut axis_values { + *value = hb_roundf(*value) / 16384.0; + } + // Make sure the effective coord vector is wide enough to // hold the highest-numbered axis we are about to write. if let Some(max_axis) = axis_indices.iter().copied().max() { diff --git a/src/tables/varc/tests.rs b/src/tables/varc/tests.rs index 77970c40..2e4eed9f 100644 --- a/src/tables/varc/tests.rs +++ b/src/tables/varc/tests.rs @@ -347,3 +347,77 @@ fn wide_axis_components_stop_at_coord_budget() { assert_eq!(comp.components.len(), MAX_COMPOSITE_COORDS / 65536); assert!(comp.components.iter().all(|c| c.coords.len() == 65536)); } + +/// A MultiItemVariationStore with the given sparse regions, each a list +/// of `(axis, start, peak, end)`, and one subtable over all of them +/// whose delta sets are the given TupleValues streams. +fn build_store(regions: &[&[(u16, f32, f32, f32)]], delta_sets: &[&[u8]]) -> Vec { + let f2 = |v: f32| ((v * 16384.0) as i16).to_be_bytes(); + let mut list = Vec::new(); + list.extend_from_slice(&(regions.len() as u16).to_be_bytes()); + let mut bodies = Vec::new(); + for region in regions { + let off = 2 + 4 * regions.len() + bodies.len(); + list.extend_from_slice(&(off as u32).to_be_bytes()); + bodies.extend_from_slice(&(region.len() as u16).to_be_bytes()); + for &(axis, start, peak, end) in *region { + bodies.extend_from_slice(&axis.to_be_bytes()); + bodies.extend_from_slice(&f2(start)); + bodies.extend_from_slice(&f2(peak)); + bodies.extend_from_slice(&f2(end)); + } + } + list.extend_from_slice(&bodies); + let mut out = Vec::new(); + out.extend_from_slice(&1u16.to_be_bytes()); // format + out.extend_from_slice(&12u32.to_be_bytes()); // region list + out.extend_from_slice(&1u16.to_be_bytes()); // one subtable + let sub_off = 12 + list.len(); + out.extend_from_slice(&(sub_off as u32).to_be_bytes()); + out.extend_from_slice(&list); + out.push(1); + out.extend_from_slice(&(regions.len() as u16).to_be_bytes()); + for i in 0..regions.len() { + out.extend_from_slice(&(i as u16).to_be_bytes()); + } + out.extend_from_slice(&build_cff2_index(delta_sets)); + out +} + +/// One component of glyph 5 that sets axis 0 to `value` (F2DOT14 +/// units) with a delta of `delta` units at axis 0 = +1. +fn varied_axis_value_table(value: i16, delta: i8) -> Vec { + let store = build_store(&[&[(0, 0.0, 1.0, 1.0)]], &[&[0x00, delta as u8]]); + let flags = VC_HAVE_AXES | VC_AXIS_VALUES_HAVE_VARIATION; + let mut record = vec![flags as u8, 0x00, 0x05]; // flags, gid 5 + record.push(0); // axisIndicesIndex + record.push(0x40); // one i16 axis value + record.extend_from_slice(&value.to_be_bytes()); + record.push(0); // axisValuesVarIndex: outer 0, inner 0 + build_varc(&[1], &[&record], Some(&store), Some(&[&[0x00, 0x00]])) +} + +#[test] +fn axis_values_plus_deltas_round_to_f2dot14() { + // HarfBuzz adds the deltas to the axis values in F2DOT14 units and + // rounds the sum to a whole F2DOT14 value, halves up. + let child_coord = |value: i16, delta: i8, coord: f32| { + let bytes = varied_axis_value_table(value, delta); + let varc = Varc::parse(&bytes).unwrap(); + varc.composite(1, &[coord]).unwrap().components[0].coords[0] + }; + // 8192 + 0.5 rounds up to 8193. + assert_eq!(child_coord(8192, 1, 0.5), 8193.0 / 16384.0); + // 8192 + 0.25 rounds down to 8192. + assert_eq!(child_coord(8192, 1, 0.25), 0.5); + // -8192 + 0.5 is a half: up, toward positive infinity. + assert_eq!(child_coord(-8192, 1, 0.5), -8191.0 / 16384.0); + // -8192 - 0.5 rounds up too. + assert_eq!(child_coord(-8192, -1, 0.5), -8192.0 / 16384.0); + // 8192 + 0.75 rounds to 8193. + assert_eq!(child_coord(8192, 3, 0.25), 8193.0 / 16384.0); + // At the default instance no delta applies. + let bytes = varied_axis_value_table(8192, 100); + let varc = Varc::parse(&bytes).unwrap(); + assert_eq!(varc.composite(1, &[]).unwrap().components[0].coords, [0.5]); +} From c80920c3b385ef51875ec04599ac61f717399e2a Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 09:10:21 -0800 Subject: [PATCH 04/13] Read VARC component records and delta sets as HarfBuzz does 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. --- src/tables/multi_var_store.rs | 246 +++++++++++--- src/tables/varc.rs | 604 +++++++++++++++++----------------- src/tables/varc/tests.rs | 195 ++++++++++- 3 files changed, 683 insertions(+), 362 deletions(-) diff --git a/src/tables/multi_var_store.rs b/src/tables/multi_var_store.rs index 4a068964..87f10295 100644 --- a/src/tables/multi_var_store.rs +++ b/src/tables/multi_var_store.rs @@ -49,12 +49,12 @@ //! CFF2Index deltaSets //! ``` //! -//! sigilbuzz's reader returns the raw bytes of one delta-set entry on -//! demand; full TupleValues decoding is the consumer's job (it depends -//! on whether the tuple represents a transform field, an axis-coord -//! delta, or an arbitrary scalar). The store handles region selection -//! and per-region scalar evaluation; consumers fold scalars back into -//! their own tuple decoding. +//! Each delta set is a `TupleValues` stream holding one tuple per region +//! of its subtable, region after region. The consumer knows the tuple +//! length (how many transform fields or axis values vary); +//! [`MultiVarStore::resolve_deltas`] sums the tuples, each scaled by +//! its region's scalar at the given coords, the way HarfBuzz's +//! `MultiItemVariationStore::get_delta` does. use alloc::collections::BTreeMap; use alloc::vec; @@ -384,23 +384,28 @@ impl<'a> MultiVarStore<'a> { } /// Raw `TupleValues` byte slice for delta set `(outer, inner)`. - /// VARC's consumers (axis-value deltas, transform-field deltas) - /// each call `decode_tuple_values` over this with the expected - /// tuple length and fold per-region scalars back in themselves. #[must_use] pub fn delta_set_bytes(&self, outer: u16, inner: u32) -> Option<&'a [u8]> { let sub = self.subtable(outer)?; sub.delta_sets.get(inner as usize).copied() } - /// Decodes the delta-set payload for `(outer, inner)` as a flat - /// `TupleValues` stream of `value_count * region_count` deltas - /// (region-major: tuple-0-region-0, tuple-0-region-1, ..., - /// tuple-1-region-0, ...) and resolves them against `coords`, + /// Resolves the delta set `(outer, inner)` against `coords`, /// returning `value_count` summed deltas, one per output value. /// - /// Returns `None` when indices are out of range or the payload - /// cannot decode the requested length. + /// The delta set is a `TupleValues` stream holding one tuple of + /// `value_count` deltas per region of the subtable, region after + /// region (region 0's tuple, then region 1's, ...), as the spec, + /// fontTools and HarfBuzz lay it out. Values past the last region's + /// tuple are ignored, as in HarfBuzz. A stream that ends before it + /// fills every tuple is malformed and adds nothing; HarfBuzz adds + /// the values it does find, so such a set varies only some of the + /// fields it names there. + /// + /// Returns `None` when `outer` or `inner` is out of range, or when + /// the delta set is too short to hold `value_count` values even for + /// one region (a control byte codes at most 64), so a huge + /// `value_count` cannot size a huge buffer. #[must_use] pub fn resolve_deltas( &self, @@ -410,21 +415,142 @@ impl<'a> MultiVarStore<'a> { coords: &[f32], ) -> Option> { let sub = self.subtable(outer)?; - let raw = sub.delta_sets.get(inner as usize).copied()?; - let region_count = sub.region_indexes.len(); - let total = value_count.checked_mul(region_count)?; - let deltas = decode_tuple_values(raw, total)?; - let scalars = self.scalars_for(&sub.region_indexes, coords); + let raw = sub.delta_sets.get(inner as usize)?; + if value_count > raw.len().saturating_mul(64) { + return None; + } let mut out = vec![0.0_f32; value_count]; - // With no regions every output delta stays zero. - if region_count > 0 { - for (slot, row) in out.iter_mut().zip(deltas.chunks_exact(region_count)) { - for (&d, scalar) in row.iter().zip(&scalars) { - *slot += d as f32 * scalar; - } + self.add_deltas(outer, inner, coords, &mut out); + Some(out) + } + + /// Adds the deltas of delta set `(outer, inner)` at `coords` to + /// `out`, region by region, the way HarfBuzz's + /// `MultiItemVariationStore::get_delta` adds them to the values it + /// varies: each region's scalar times its tuple, added in place. + /// Regions whose scalar is zero are skipped. Does nothing when the + /// indices are out of range or the set is too short for its tuples. + pub(crate) fn add_deltas(&self, outer: u16, inner: u32, coords: &[f32], out: &mut [f32]) { + let Some(sub) = self.subtable(outer) else { + return; + }; + let Some(&raw) = sub.delta_sets.get(inner as usize) else { + return; + }; + let needed = out.len().saturating_mul(sub.region_indexes.len()); + if !TupleFetcher::new(raw).holds(needed) { + return; + } + let mut values = TupleFetcher::new(raw); + let mut skip = 0usize; + for scalar in self.scalars_for(&sub.region_indexes, coords) { + if scalar == 0.0 { + skip = skip.saturating_add(out.len()); + continue; } + values.skip(core::mem::take(&mut skip)); + values.add_to(out, scalar); + } + } +} + +/// Reads a `TupleValues` stream lazily, as HarfBuzz's +/// `TupleValues::fetcher_t` does: a run that does not fit the +/// remaining bytes ends the stream, and reads past the end add nothing. +struct TupleFetcher<'a> { + data: &'a [u8], + /// Values left in the current run. + run: usize, + /// Bytes per value in the current run: 0 for a zero run. + width: usize, +} + +impl<'a> TupleFetcher<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + run: 0, + width: 0, + } + } + + /// Starts the next run if the current one is used up. False once + /// the stream has ended. + fn ensure_run(&mut self) -> bool { + if self.run > 0 { + return true; + } + let Some((&control, rest)) = self.data.split_first() else { + return false; + }; + let run = usize::from(control & 0x3F) + 1; + let width = match control & 0xC0 { + 0x80 => 0, + 0x00 => 1, + 0x40 => 2, + _ => 4, + }; + if rest.len() < run * width { + self.data = &[]; + return false; + } + self.data = rest; + self.run = run; + self.width = width; + true + } + + /// The value at the head of the current run. The caller checked + /// [`Self::ensure_run`]. + fn take(&mut self) -> i32 { + self.run -= 1; + let (value, rest) = match self.width { + 0 => (0, self.data), + 1 => (i32::from(self.data[0] as i8), &self.data[1..]), + 2 => ( + i32::from(i16::from_be_bytes([self.data[0], self.data[1]])), + &self.data[2..], + ), + _ => ( + i32::from_be_bytes([self.data[0], self.data[1], self.data[2], self.data[3]]), + &self.data[4..], + ), + }; + self.data = rest; + value + } + + /// Whether the stream holds at least `n` values, every run fitting + /// its bytes. + fn holds(mut self, n: usize) -> bool { + let mut seen = 0usize; + while seen < n { + if !self.ensure_run() { + return false; + } + seen += self.run; + self.data = &self.data[self.run * self.width..]; + self.run = 0; + } + true + } + + fn skip(&mut self, mut n: usize) { + while n > 0 && self.ensure_run() { + let k = n.min(self.run); + self.run -= k; + self.data = &self.data[k * self.width..]; + n -= k; + } + } + + fn add_to(&mut self, out: &mut [f32], scale: f32) { + for slot in out { + if !self.ensure_run() { + return; + } + *slot += self.take() as f32 * scale; } - Some(out) } } @@ -443,28 +569,28 @@ fn sparse_region_scalar(region: &SparseRegion, coords: &[f32]) -> f32 { } /// Triangular region falloff for one axis. Returns `1.0` at `peak`, -/// tapering linearly to `0.0` at `start` and `end`. Mirrors the -/// classic `supportScalar` from the OpenType spec. +/// tapering linearly to `0.0` at `start` and `end`, as HarfBuzz's +/// `VarRegionAxis::evaluate` does. An axis whose peak is zero does not +/// constrain the region, and so does an invalid triple: one out of +/// order, or one that crosses zero. fn axis_scalar(start: f32, peak: f32, end: f32, coord: f32) -> f32 { - if peak == 0.0 && start <= 0.0 && end >= 0.0 { + if peak == 0.0 || coord == peak { return 1.0; } - if coord == peak { + if coord == 0.0 { + return 0.0; + } + if start > peak || peak > end || (start < 0.0 && end > 0.0) { return 1.0; } - if coord < start || coord > end { + if coord <= start || end <= coord { return 0.0; } if coord < peak { - if peak == start { - return 0.0; - } - return (coord - start) / (peak - start); - } - if peak == end { - return 0.0; + (coord - start) / (peak - start) + } else { + (end - coord) / (end - peak) } - (end - coord) / (end - peak) } // ---------------------------------------------------------------------- @@ -482,10 +608,11 @@ fn axis_scalar(start: f32, peak: f32, end: f32, coord: f32) -> f32 { // When DELTAS_ARE_ZERO and DELTAS_ARE_WORDS are both set, the run // carries i32 deltas instead. (boring-expansion-spec extension.) -/// Decodes a `TupleValues` byte stream into exactly `count` deltas. -/// Returns `None` on truncation or if the stream encodes more deltas -/// than `count`. -pub(crate) fn decode_tuple_values(data: &[u8], count: usize) -> Option> { +/// Decodes `count` values from the head of a `TupleValues` byte stream, +/// returning them with the number of bytes they took. Returns `None` +/// on truncation or when a run goes past `count`, as HarfBuzz's +/// `TupleValues::decompile` fails. +pub(crate) fn decode_tuple_values(data: &[u8], count: usize) -> Option<(Vec, usize)> { // One control byte yields at most 64 deltas, so `data` cannot // encode more than `64 * data.len()` of them. Reserve no more. let mut out: Vec = Vec::with_capacity(count.min(data.len().saturating_mul(64))); @@ -534,7 +661,7 @@ pub(crate) fn decode_tuple_values(data: &[u8], count: usize) -> Option> } } if out.len() == count { - Some(out) + Some((out, i)) } else { None } @@ -661,12 +788,13 @@ mod tests { #[test] fn resolve_deltas_sums_region_contributions() { // Two regions: axis 0 peak +1, axis 0 peak -1. - // One delta-set with two values: [r0:100, r1:50, r0:-10, r1:5] + // One delta-set with two values per region, region after + // region: [r0:100, r0:-10, r1:50, r1:5] // value 0 -> 100*scalar(r0) + 50*scalar(r1) // value 1 -> -10*scalar(r0) + 5*scalar(r1) // Build TupleValues: 4 i8 deltas, control = 0x03 (no zero, no // words, run_len=4). - let payload = vec![0x03_u8, 100, 50, (-10_i8) as u8, 5]; + let payload = vec![0x03_u8, 100, (-10_i8) as u8, 50, 5]; let bytes = build_store( &[vec![(0, 0.0, 1.0, 1.0)], vec![(0, -1.0, -1.0, 0.0)]], &[0, 1], @@ -683,25 +811,41 @@ mod tests { assert!((resolved[1] - 5.0).abs() < 1e-3); } + #[test] + fn axis_falloff_matches_harfbuzz_for_odd_triples() { + // A zero peak, an out-of-order triple, and one that crosses + // zero all leave the region unconstrained on that axis. + assert_eq!(axis_scalar(-0.5, 0.0, 0.5, 0.3), 1.0); + assert_eq!(axis_scalar(0.5, 0.25, 1.0, 0.3), 1.0); + assert_eq!(axis_scalar(-0.5, 0.5, 1.0, 0.3), 1.0); + // At the default an axis with a nonzero peak contributes zero. + assert_eq!(axis_scalar(0.0, 0.5, 1.0, 0.0), 0.0); + // The ends themselves are zero; inside it tapers linearly. + assert_eq!(axis_scalar(0.25, 0.5, 1.0, 0.25), 0.0); + assert_eq!(axis_scalar(0.25, 0.5, 1.0, 1.0), 0.0); + assert_eq!(axis_scalar(0.0, 0.5, 1.0, 0.75), 0.5); + assert_eq!(axis_scalar(-1.0, -1.0, 0.0, -0.25), 0.25); + } + #[test] fn tuple_values_decodes_zero_run() { // 0x80 | 0x03 = run of 4 zeros, no payload. - let out = decode_tuple_values(&[0x83], 4).unwrap(); - assert_eq!(out, vec![0, 0, 0, 0]); + let out = decode_tuple_values(&[0x83, 0x55], 4).unwrap(); + assert_eq!(out, (vec![0, 0, 0, 0], 1)); } #[test] fn tuple_values_decodes_word_run() { // 0x40 | 0x01 = run of 2 i16s. Payload = 0x0064, 0xFFFF (-1). let out = decode_tuple_values(&[0x41, 0x00, 0x64, 0xFF, 0xFF], 2).unwrap(); - assert_eq!(out, vec![100, -1]); + assert_eq!(out, (vec![100, -1], 5)); } #[test] fn tuple_values_zero_words_combo_decodes_i32() { // 0xC0 | 0x00 = run of 1 i32. Payload = 0x00010000 = 65536. let out = decode_tuple_values(&[0xC0, 0x00, 0x01, 0x00, 0x00], 1).unwrap(); - assert_eq!(out, vec![65536]); + assert_eq!(out, (vec![65536], 5)); } #[test] diff --git a/src/tables/varc.rs b/src/tables/varc.rs index 85222300..1eb5b34b 100644 --- a/src/tables/varc.rs +++ b/src/tables/varc.rs @@ -37,9 +37,10 @@ //! # Component record //! //! Each component record is a flag-driven variable-length blob. See -//! the boring-expansion-spec `VARC.md` for the full table; the comment -//! at `Varc::resolve_component` enumerates which fields appear under -//! which flags. +//! the boring-expansion-spec `VARC.md` for the full table; +//! `Varc::read_component` reads the fields in record order, as +//! HarfBuzz's `VarComponent::decompile_record` does, and +//! `Varc::resolve_component` evaluates them as its `get_path_at` does. //! //! # Scope //! @@ -52,7 +53,7 @@ use alloc::vec::Vec; use crate::error::{Error, Result}; use crate::tables::layout::Coverage; -use crate::tables::multi_var_store::{read_cff2_index, MultiVarStore}; +use crate::tables::multi_var_store::{decode_tuple_values, read_cff2_index, MultiVarStore}; use crate::tables::parse::{hb_roundf, Reader}; /// A parsed `VARC` table. @@ -62,7 +63,7 @@ pub struct Varc<'a> { var_store: Option>, /// One axis-indices tuple per CFF2 INDEX entry; outer index of the /// component's `axisIndicesIndex` selects one of these. - axis_indices_lists: Vec>, + axis_indices_lists: Vec>, /// One byte slice per glyph record. Indexed by the position of /// `gid` inside the coverage table (i.e. the same `index_of` /// returns). @@ -115,6 +116,14 @@ const VC_HAVE_SKEW_X: u32 = 1 << 13; const VC_HAVE_SKEW_Y: u32 = 1 << 14; const VC_RESERVED_MASK: u32 = !((1u32 << 15) - 1); +/// `VarIdx` meaning "no variation". +const NO_VARIATION: u32 = 0xFFFF_FFFF; + +/// HarfBuzz's `HB_VAR_COMPOSITE_MAX_AXES`: a component coord vector +/// holds at most this many axes, and an axis index past them is +/// ignored. +const MAX_COMPONENT_AXES: usize = 4096; + /// Upper bound on the coordinate values one composite carries across /// all its components. Each component copies a coord vector, widened /// to the highest axis index it lists, so a few record bytes can ask @@ -158,17 +167,8 @@ impl<'a> Varc<'a> { Some(MultiVarStore::parse(bytes)?) }; - // axisIndicesList is a CFF2 INDEX of TupleValues blocks. Each - // entry decodes to a list of u16 axis indices, except the - // length is the number of axis indices, which equals the - // number of values in the TupleValues stream when each value - // occupies one slot. The boring-expansion-spec encodes axis - // indices as a packed delta-from-previous list (gvar's packed - // point-numbers form), but in-the-wild fonts so far emit the - // simpler "one i8/i16 per axis index" form, which our - // decode_tuple_values handles directly. We reconstruct the - // absolute axis indices from a running cumulative sum, which - // collapses to the identity when the deltas are absolute. + // axisIndicesList is a CFF2 INDEX of TupleValues blocks, one + // absolute axis index per value. let axis_indices_lists = if axis_indices_off == 0 { Vec::new() } else { @@ -176,7 +176,7 @@ impl<'a> Varc<'a> { let entries = read_cff2_index(&mut sr)?; let mut out = Vec::with_capacity(entries.len()); for entry in entries { - out.push(decode_axis_indices(entry)?); + out.push(decode_axis_indices(entry)); } out }; @@ -213,9 +213,12 @@ impl<'a> Varc<'a> { /// Resolves the component list for `gid` at the given normalized /// axis coords. Returns `None` for uncovered gids. /// - /// A malformed component ends the list early. So does a component - /// that would push the total length of all component coord vectors - /// past 2^20 values. + /// Components are read as HarfBuzz's `decompile_record` reads them + /// and evaluated as its `VarComponent::get_path_at` does. A component + /// whose glyph id does not fit 16 bits names no glyph and is left + /// out. A malformed component ends the list early. So does a + /// component that would push the total length of all component + /// coord vectors past 2^20 values. #[must_use] pub fn composite(&self, gid: u16, coords: &[f32]) -> Option { let idx = self.coverage.index_of(gid)? as usize; @@ -228,9 +231,12 @@ impl<'a> Varc<'a> { // end mid-record means the font is malformed; we skip the // rest rather than error so a single bad glyph doesn't // tank the rest of the document. - let Ok(c) = self.resolve_component(&mut r, coords) else { + let Ok(record) = self.read_component(&mut r) else { break; }; + let Some(c) = self.resolve_component(&record, coords) else { + continue; + }; total_coords = total_coords.saturating_add(c.coords.len()); if total_coords > MAX_COMPOSITE_COORDS { break; @@ -240,186 +246,233 @@ impl<'a> Varc<'a> { Some(composite) } - /// Decodes one component record at the reader's current position - /// and resolves it against `coords`. - fn resolve_component(&self, r: &mut Reader<'_>, coords: &[f32]) -> Result { + /// Decodes the component record at the reader's position, in the + /// order the spec and HarfBuzz read it: flags, glyph id, condition + /// index, axis indices index and axis values, the two variation + /// indices, the transform fields, then one discarded uint32var per + /// reserved flag bit. + fn read_component(&self, r: &mut Reader<'_>) -> Result> { let flags = read_uint32var(r)?; - if flags & VC_RESERVED_MASK != 0 { - return Err(Error::Malformed { - offset: r.position(), - context: "VARC component has reserved flag bits set", - }); - } - let gid = if flags & VC_GID_IS_24BIT != 0 { let bytes = r.read_bytes(3)?; - ((u32::from(bytes[0]) << 16) | (u32::from(bytes[1]) << 8) | u32::from(bytes[2])) as u16 + (u32::from(bytes[0]) << 16) | (u32::from(bytes[1]) << 8) | u32::from(bytes[2]) } else { - r.read_u16()? + u32::from(r.read_u16()?) }; - - // ConditionList: read and discard. sigilbuzz does not gate - // components on conditions yet. - if flags & VC_HAVE_CONDITION != 0 { - let _ = read_uint32var(r)?; - } - - // The child starts from the parent's coord vector, and - // HAVE_AXES values replace the listed axes. - let mut effective_coords: Vec = coords.to_vec(); - - if flags & VC_HAVE_AXES != 0 { - let axis_indices_index = read_uint32var(r)? as usize; - let axis_indices = - self.axis_indices_lists - .get(axis_indices_index) - .ok_or(Error::Malformed { - offset: r.position(), - context: "VARC component axisIndicesIndex out of range", - })?; - let n = axis_indices.len(); - // The TupleValues stream packs `n` values in F2DOT14 units. - // They stay in those units while their deltas are added. - let raw_values = decode_tuple_values_in_reader(r, n).ok_or(Error::Malformed { - offset: r.position(), - context: "VARC component axisValues TupleValues truncated", - })?; - let mut axis_values: Vec = raw_values.into_iter().map(|v| v as f32).collect(); - - // Optional per-value variation deltas, also in F2DOT14 - // units. HarfBuzz adds none at the default instance. - if flags & VC_AXIS_VALUES_HAVE_VARIATION != 0 { - let var_idx = read_uint32var(r)?; - if let Some(store) = self.var_store.as_ref().filter(|_| !coords.is_empty()) { - let outer = (var_idx >> 16) as u16; - let inner = var_idx & 0xFFFF; - if let Some(deltas) = store.resolve_deltas(outer, inner, n, coords) { - for (value, d) in axis_values.iter_mut().zip(&deltas) { - *value += d; - } - } - } - } - - // HarfBuzz keeps coords as whole F2DOT14 values, so each - // value plus its deltas rounds to one, halves up, before it - // becomes the child's coord. - for value in &mut axis_values { - *value = hb_roundf(*value) / 16384.0; - } - - // Make sure the effective coord vector is wide enough to - // hold the highest-numbered axis we are about to write. - if let Some(max_axis) = axis_indices.iter().copied().max() { - let needed = max_axis as usize + 1; - if effective_coords.len() < needed { - effective_coords.resize(needed, 0.0); - } - } - for (axis_index, value) in axis_indices.iter().zip(axis_values.iter()) { - effective_coords[*axis_index as usize] = *value; - } - } - - // Transform variation index: present when TRANSFORM_HAS_VARIATION - // is set, regardless of which transform fields are present. - let transform_var_idx = if flags & VC_TRANSFORM_HAS_VARIATION != 0 { + let condition_index = if flags & VC_HAVE_CONDITION != 0 { Some(read_uint32var(r)?) } else { None }; - - // Read the present transform fields in spec order. - let mut tx = 0.0_f32; - let mut ty = 0.0_f32; - let mut rotation = 0.0_f32; // angle * π - let mut sx = 1.0_f32; - let mut sy = 1.0_f32; - let mut skew_x = 0.0_f32; - let mut skew_y = 0.0_f32; - let mut tcx = 0.0_f32; - let mut tcy = 0.0_f32; - - // Track which transform fields were present; the variation - // delta tuple has one slot per present field, in spec order. - let mut present_fields: Vec = Vec::new(); - - if flags & VC_HAVE_TRANSLATE_X != 0 { - tx = f32::from(r.read_i16()?); - present_fields.push(TransformField::TranslateX); + let (axis_indices, axis_values) = if flags & VC_HAVE_AXES != 0 { + // An index past the list names an empty tuple, as in + // HarfBuzz, so no axis values follow. + let index = read_uint32var(r)? as usize; + let indices = self + .axis_indices_lists + .get(index) + .map_or(&[][..], Vec::as_slice); + let values = + decode_tuple_values_in_reader(r, indices.len()).ok_or(Error::Malformed { + offset: r.position(), + context: "VARC component axisValues TupleValues truncated", + })?; + (indices, values) + } else { + (&[][..], Vec::new()) + }; + let axis_values_var_index = if flags & VC_AXIS_VALUES_HAVE_VARIATION != 0 { + Some(read_uint32var(r)?) + } else { + None + }; + let transform_var_index = if flags & VC_TRANSFORM_HAS_VARIATION != 0 { + Some(read_uint32var(r)?) + } else { + None + }; + let mut fields = Vec::new(); + for (flag, field) in TRANSFORM_FIELDS { + if flags & flag != 0 { + fields.push((field, r.read_i16()?)); + } } - if flags & VC_HAVE_TRANSLATE_Y != 0 { - ty = f32::from(r.read_i16()?); - present_fields.push(TransformField::TranslateY); + for _ in 0..(flags & VC_RESERVED_MASK).count_ones() { + read_uint32var(r)?; } - if flags & VC_HAVE_ROTATION != 0 { - rotation = read_f4dot12(r)?; - present_fields.push(TransformField::Rotation); + Ok(ComponentRecord { + flags, + gid, + condition_index, + axis_indices, + axis_values, + axis_values_var_index, + transform_var_index, + fields, + }) + } + + /// Evaluates `record` at `coords`, the coords of the glyph the + /// component belongs to. `None` when the component draws nothing. + fn resolve_component( + &self, + record: &ComponentRecord<'_>, + coords: &[f32], + ) -> Option { + let gid = u16::try_from(record.gid).ok()?; + + // Axis values and their deltas, in F2DOT14 units. + let mut axis_values: Vec = record.axis_values.iter().map(|&v| v as f32).collect(); + if let Some(index) = record.axis_values_var_index { + self.add_deltas(index, coords, &mut axis_values); } - if flags & VC_HAVE_SCALE_X != 0 { - sx = read_f6dot10(r)?; - present_fields.push(TransformField::ScaleX); + + // The child starts from the parent's coord vector, and the + // listed axes take the component's values. HarfBuzz keeps + // coords as whole F2DOT14 values, so each value plus its deltas + // rounds to one, halves up. It holds at most + // `MAX_COMPONENT_AXES` coords and ignores an axis index past + // them. + let mut child_coords = coords.to_vec(); + for (&axis, &value) in record.axis_indices.iter().zip(&axis_values) { + let axis = axis as usize; + if axis >= MAX_COMPONENT_AXES { + continue; + } + if child_coords.len() <= axis { + child_coords.resize(axis + 1, 0.0); + } + child_coords[axis] = hb_roundf(value) / 16384.0; } - if flags & VC_HAVE_SCALE_Y != 0 { - sy = read_f6dot10(r)?; - present_fields.push(TransformField::ScaleY); + + // Transform fields in their stored units, plus their deltas, + // then divided down: F4.12 for angles, F6.10 for scales. + let mut values: Vec = record.fields.iter().map(|&(_, v)| f32::from(v)).collect(); + if let Some(index) = record.transform_var_index { + self.add_deltas(index, coords, &mut values); } - if flags & VC_HAVE_SKEW_X != 0 { - skew_x = read_f4dot12(r)?; - present_fields.push(TransformField::SkewX); + let mut t = Decomposed::default(); + for (&(field, _), &v) in record.fields.iter().zip(&values) { + match field { + TransformField::TranslateX => t.tx = v, + TransformField::TranslateY => t.ty = v, + TransformField::Rotation => t.rotation = v / 4096.0, + TransformField::ScaleX => t.sx = v / 1024.0, + TransformField::ScaleY => t.sy = v / 1024.0, + TransformField::SkewX => t.skew_x = v / 4096.0, + TransformField::SkewY => t.skew_y = v / 4096.0, + TransformField::TCenterX => t.tcx = v, + TransformField::TCenterY => t.tcy = v, + } } - if flags & VC_HAVE_SKEW_Y != 0 { - skew_y = read_f4dot12(r)?; - present_fields.push(TransformField::SkewY); + // ScaleY defaults to ScaleX, not to 1. + if record.flags & VC_HAVE_SCALE_Y == 0 { + t.sy = t.sx; } - if flags & VC_HAVE_TCENTER_X != 0 { - tcx = f32::from(r.read_i16()?); - present_fields.push(TransformField::TCenterX); + + Some(VarcComponent { + gid, + transform: t.to_affine(), + coords: child_coords, + }) + } + + /// Adds the deltas of the variation index `index` at `coords` to + /// `values`. Nothing is added at the default instance (empty + /// coords), for `NO_VARIATION`, or without a store, as in HarfBuzz. + fn add_deltas(&self, index: u32, coords: &[f32], values: &mut [f32]) { + if coords.is_empty() || index == NO_VARIATION { + return; } - if flags & VC_HAVE_TCENTER_Y != 0 { - tcy = f32::from(r.read_i16()?); - present_fields.push(TransformField::TCenterY); + if let Some(store) = &self.var_store { + store.add_deltas((index >> 16) as u16, index & 0xFFFF, coords, values); } + } +} - // Apply transform variation deltas, if any. - if let Some(var_idx) = transform_var_idx { - if let Some(store) = &self.var_store { - let outer = (var_idx >> 16) as u16; - let inner = var_idx & 0xFFFF; - let n = present_fields.len(); - if let Some(deltas) = store.resolve_deltas(outer, inner, n, coords) { - for (field, delta) in present_fields.iter().zip(deltas.iter()) { - match field { - TransformField::TranslateX => tx += delta, - TransformField::TranslateY => ty += delta, - TransformField::Rotation => rotation += delta / 4096.0, - TransformField::ScaleX => sx += delta / 1024.0, - TransformField::ScaleY => sy += delta / 1024.0, - TransformField::SkewX => skew_x += delta / 4096.0, - TransformField::SkewY => skew_y += delta / 4096.0, - TransformField::TCenterX => tcx += delta, - TransformField::TCenterY => tcy += delta, - } - } - } - } - } +/// One component record as stored, before it is evaluated at any +/// coords. +struct ComponentRecord<'t> { + flags: u32, + /// 16- or 24-bit glyph id. + gid: u32, + /// Read for HAVE_CONDITION; conditions are not evaluated yet. + #[allow(dead_code)] + condition_index: Option, + /// The axes the component sets, from the axis indices list. + axis_indices: &'t [u32], + /// One value per axis index, in F2DOT14 units. + axis_values: Vec, + axis_values_var_index: Option, + transform_var_index: Option, + /// The transform fields present, in spec order, as stored. + fields: Vec<(TransformField, i16)>, +} - // Build the affine. boring-expansion-spec composes: - // T(tx + tcx, ty + tcy) * - // R(rotation * π) * S(sx, sy) * - // Skew(-skewX * π, skewY * π) * - // T(-tcx, -tcy) - let transform = compose_affine(tx, ty, rotation, sx, sy, skew_x, skew_y, tcx, tcy); +/// A component transform in its decomposed form, angles in half-turns. +struct Decomposed { + tx: f32, + ty: f32, + rotation: f32, + sx: f32, + sy: f32, + skew_x: f32, + skew_y: f32, + tcx: f32, + tcy: f32, +} - Ok(VarcComponent { - gid, - transform, - coords: effective_coords, - }) +impl Default for Decomposed { + fn default() -> Self { + Self { + tx: 0.0, + ty: 0.0, + rotation: 0.0, + sx: 1.0, + sy: 1.0, + skew_x: 0.0, + skew_y: 0.0, + tcx: 0.0, + tcy: 0.0, + } } } +impl Decomposed { + /// The affine the boring-expansion spec composes: + /// + /// ```text + /// T(tx + tcx, ty + tcy) * R(rotation * π) * S(sx, sy) * + /// Skew(-skewX * π, skewY * π) * T(-tcx, -tcy) + /// ``` + fn to_affine(&self) -> [f32; 6] { + compose_affine( + self.tx, + self.ty, + self.rotation, + self.sx, + self.sy, + self.skew_x, + self.skew_y, + self.tcx, + self.tcy, + ) + } +} + +/// The transform fields in record order, with their flags. +const TRANSFORM_FIELDS: [(u32, TransformField); 9] = [ + (VC_HAVE_TRANSLATE_X, TransformField::TranslateX), + (VC_HAVE_TRANSLATE_Y, TransformField::TranslateY), + (VC_HAVE_ROTATION, TransformField::Rotation), + (VC_HAVE_SCALE_X, TransformField::ScaleX), + (VC_HAVE_SCALE_Y, TransformField::ScaleY), + (VC_HAVE_SKEW_X, TransformField::SkewX), + (VC_HAVE_SKEW_Y, TransformField::SkewY), + (VC_HAVE_TCENTER_X, TransformField::TCenterX), + (VC_HAVE_TCENTER_Y, TransformField::TCenterY), +]; + /// One transform field, in spec order. Used to map variation deltas /// back onto the right slot. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -469,141 +522,84 @@ fn read_uint32var(r: &mut Reader<'_>) -> Result { }) } -/// Decodes a TupleValues stream of exactly `count` deltas from the -/// reader, advancing the cursor by however many bytes the encoding -/// uses. Returns `None` on truncation or overrun. +/// Decodes `count` TupleValues from the reader, advancing it past +/// them. Returns `None` on truncation or when a run goes past `count`, +/// where HarfBuzz's `TupleValues::decompile` fails. fn decode_tuple_values_in_reader(r: &mut Reader<'_>, count: usize) -> Option> { - // We need to know how many bytes the stream consumed. Walk the - // remaining buffer manually, mirroring the inner loop of - // decode_tuple_values, and advance the reader. - let start = r.position(); - let remaining = r.remaining(); - let buf = r.peek_bytes(remaining).ok()?; - // One control byte yields at most 64 values, so reserve no more - // than the remaining bytes can encode. - let mut out: Vec = Vec::with_capacity(count.min(buf.len().saturating_mul(64))); - let mut i = 0usize; - while out.len() < count { - if i >= buf.len() { - return None; - } - let ctrl = buf[i]; - i += 1; - let run_len = (ctrl & 0x3F) as usize + 1; - let zeros = ctrl & 0x80 != 0; - let words = ctrl & 0x40 != 0; - for _ in 0..run_len { - if out.len() >= count { - return None; + let buf = r.peek_bytes(r.remaining()).ok()?; + let (values, used) = decode_tuple_values(buf, count)?; + r.skip(used).ok()?; + Some(values) +} + +/// Decodes one axis indices tuple the way HarfBuzz's +/// `TupleValues::iter_t` walks it, malformed streams included: a run +/// that does not fit the bytes left yields one zero and the walk goes +/// on from the next byte. Each value is read as unsigned, as HarfBuzz +/// stores the indices, so a negative one names no axis. +fn decode_axis_indices(data: &[u8]) -> Vec { + struct Walk<'d> { + data: &'d [u8], + p: usize, + run: usize, + width: usize, + value: i32, + } + impl Walk<'_> { + fn ensure_run(&mut self) -> bool { + if self.run > 0 { + return true; + } + let Some(&control) = self.data.get(self.p) else { + self.value = 0; + return false; + }; + self.p += 1; + self.run = usize::from(control & 0x3F) + 1; + self.width = match control & 0xC0 { + 0x80 => 0, + 0x00 => 1, + 0x40 => 2, + _ => 4, + }; + if self.data.len() - self.p < self.run * self.width { + self.run = 0; + self.value = 0; + return false; } - let delta: i32 = match (zeros, words) { - (true, false) => 0, - (true, true) => { - if i + 4 > buf.len() { - return None; - } - let v = i32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]); - i += 4; - v - } - (false, true) => { - if i + 2 > buf.len() { - return None; - } - let v = i32::from(i16::from_be_bytes([buf[i], buf[i + 1]])); - i += 2; - v - } - (false, false) => { - if i >= buf.len() { - return None; - } - let v = buf[i] as i8; - i += 1; - i32::from(v) - } + true + } + fn read_value(&mut self) { + let b = &self.data[self.p..self.p + self.width]; + self.value = match *b { + [] => 0, + [x] => i32::from(x as i8), + [x, y] => i32::from(i16::from_be_bytes([x, y])), + [a, b2, c, d] => i32::from_be_bytes([a, b2, c, d]), + _ => 0, }; - out.push(delta); + self.p += self.width; } } - if out.len() == count { - // Advance reader. - r.seek(start + i).ok()?; - Some(out) - } else { - None + let mut walk = Walk { + data, + p: 0, + run: 0, + width: 0, + value: 0, + }; + if walk.ensure_run() { + walk.read_value(); } -} - -/// Decodes a TupleValues stream of axis indices. boring-expansion-spec -/// allows two encodings; sigilbuzz handles the absolute form (one -/// value per axis index) which is what fontTools and HarfBuzz emit. -fn decode_axis_indices(data: &[u8]) -> Result> { - // Determine how many axis indices are encoded by walking the - // entire byte stream as a TupleValues list and treating each - // value as a u16 index. - let mut out: Vec = Vec::new(); - let mut i = 0usize; - while i < data.len() { - let ctrl = data[i]; - i += 1; - let run_len = (ctrl & 0x3F) as usize + 1; - let zeros = ctrl & 0x80 != 0; - let words = ctrl & 0x40 != 0; - for _ in 0..run_len { - let v: i32 = match (zeros, words) { - (true, false) => 0, - (true, true) => { - if i + 4 > data.len() { - return Err(Error::Malformed { - offset: i, - context: "VARC axisIndices i32 entry truncated", - }); - } - let v = i32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]); - i += 4; - v - } - (false, true) => { - if i + 2 > data.len() { - return Err(Error::Malformed { - offset: i, - context: "VARC axisIndices i16 entry truncated", - }); - } - let v = i32::from(i16::from_be_bytes([data[i], data[i + 1]])); - i += 2; - v - } - (false, false) => { - if i >= data.len() { - return Err(Error::Malformed { - offset: i, - context: "VARC axisIndices i8 entry truncated", - }); - } - let v = data[i] as i8; - i += 1; - i32::from(v) - } - }; - out.push(v as u16); + let mut out = Vec::new(); + while walk.run > 0 || walk.p < data.len() { + out.push(walk.value as u32); + walk.run = walk.run.saturating_sub(1); + if walk.ensure_run() { + walk.read_value(); } } - Ok(out) -} - -/// Reads an F4.12 fixed-point as `f32`. boring-expansion-spec uses -/// this for rotation, skew (each multiplied by π in radians). -fn read_f4dot12(r: &mut Reader<'_>) -> Result { - let raw = r.read_i16()?; - Ok(f32::from(raw) / 4096.0) -} - -/// Reads an F6.10 fixed-point as `f32`. Used for scale fields. -fn read_f6dot10(r: &mut Reader<'_>) -> Result { - let raw = r.read_i16()?; - Ok(f32::from(raw) / 1024.0) + out } /// Builds the affine matrix from VARC's transform fields. Matches the diff --git a/src/tables/varc/tests.rs b/src/tables/varc/tests.rs index 2e4eed9f..99f1f32d 100644 --- a/src/tables/varc/tests.rs +++ b/src/tables/varc/tests.rs @@ -329,23 +329,25 @@ fn huge_rotation_delta_does_not_hang() { record.extend_from_slice(&0i16.to_be_bytes()); // rotation let bytes = build_varc(&[1], &[&record], Some(&store), None); let varc = Varc::parse(&bytes).unwrap(); - let comp = varc.composite(1, &[]).unwrap(); + // Deltas apply only away from the default instance; the store's + // one region has no axes, so its scalar is 1 at any coords. + let comp = varc.composite(1, &[0.0]).unwrap(); assert_eq!(comp.components.len(), 1); } #[test] fn wide_axis_components_stop_at_coord_budget() { - // Each 6-byte component lists axis 65535, so its coord vector - // grows to 65536 values. 100 000 of them used to allocate - // about 26 GB. - let axis_indices: &[u8] = &[0x40, 0xFF, 0xFF]; // one i16: 65535 + // Each 6-byte component lists axis 4095, the last one HarfBuzz + // keeps, so its coord vector grows to 4096 values. 100 000 of them + // would allocate about 1.6 GB. + let axis_indices: &[u8] = &[0x40, 0x0F, 0xFF]; // one i16: 4095 let component: [u8; 6] = [VC_HAVE_AXES as u8, 0x00, 0x05, 0x00, 0x00, 0x00]; let record = component.repeat(100_000); let bytes = build_varc(&[1], &[&record], None, Some(&[axis_indices])); let varc = Varc::parse(&bytes).unwrap(); let comp = varc.composite(1, &[]).unwrap(); - assert_eq!(comp.components.len(), MAX_COMPOSITE_COORDS / 65536); - assert!(comp.components.iter().all(|c| c.coords.len() == 65536)); + assert_eq!(comp.components.len(), MAX_COMPOSITE_COORDS / 4096); + assert!(comp.components.iter().all(|c| c.coords.len() == 4096)); } /// A MultiItemVariationStore with the given sparse regions, each a list @@ -421,3 +423,182 @@ fn axis_values_plus_deltas_round_to_f2dot14() { let varc = Varc::parse(&bytes).unwrap(); assert_eq!(varc.composite(1, &[]).unwrap().components[0].coords, [0.5]); } + +/// The first component of glyph 1 in a table with `record` and the +/// given store and axis indices lists. +fn first( + record: &[u8], + store: Option<&[u8]>, + lists: Option<&[&[u8]]>, + coords: &[f32], +) -> VarcComposite { + let bytes = build_varc(&[1], &[record], store, lists); + Varc::parse(&bytes).unwrap().composite(1, coords).unwrap() +} + +#[test] +fn scale_y_defaults_to_scale_x() { + // The spec's default for ScaleY is ScaleX, not 1. + let mut record = vec![0x81, 0x00, 0x00, 0x05]; // uint32var HAVE_SCALE_X, gid 5 + record.extend_from_slice(&1536i16.to_be_bytes()); // 1.5 + let c = first(&record, None, None, &[]); + let t = c.components[0].transform; + assert!( + (t[0] - 1.5).abs() < 1e-6 && (t[3] - 1.5).abs() < 1e-6, + "{t:?}" + ); +} + +#[test] +fn reserved_flag_bits_each_skip_one_uint32var() { + // Bits 15 and 20 are reserved. Each costs one uint32var after the + // transform fields, which readers skip, and the next component + // reads as usual. + let flags = (1u32 << 15) | (1 << 20) | VC_HAVE_TRANSLATE_X; + let mut record = vec![0xC0 | (flags >> 16) as u8, (flags >> 8) as u8, flags as u8]; + record.extend_from_slice(&[0x00, 0x05]); // gid 5 + record.extend_from_slice(&7i16.to_be_bytes()); // translate x + record.extend_from_slice(&[0x05, 0x81, 0x2C]); // two reserved uint32vars + record.extend_from_slice(&[VC_HAVE_TRANSLATE_X as u8, 0x00, 0x06]); + record.extend_from_slice(&9i16.to_be_bytes()); + let c = first(&record, None, None, &[]); + let got: Vec<(u16, f32)> = c + .components + .iter() + .map(|c| (c.gid, c.transform[4])) + .collect(); + assert_eq!(got, [(5, 7.0), (6, 9.0)]); +} + +#[test] +fn axis_values_var_index_is_read_without_axes() { + // AXIS_VALUES_HAVE_VARIATION brings its index whether or not the + // component has axes, so the translation after it reads right. + let flags = VC_AXIS_VALUES_HAVE_VARIATION | VC_HAVE_TRANSLATE_X; + let mut record = vec![flags as u8, 0x00, 0x05, 0x00]; + record.extend_from_slice(&200i16.to_be_bytes()); + let c = first(&record, None, None, &[0.5]); + assert_eq!(c.components[0].transform[4], 200.0); +} + +#[test] +fn delta_sets_hold_one_tuple_per_region() { + // Two regions (axis 0 and axis 1, each peaking at +1) and two + // translations. The delta set is region 0's tuple, then region 1's: + // [10, 20] then [-3, 5]. + let store = build_store( + &[&[(0, 0.0, 1.0, 1.0)], &[(1, 0.0, 1.0, 1.0)]], + &[&[0x03, 10, 20, (-3i8) as u8, 5]], + ); + let flags = VC_TRANSFORM_HAS_VARIATION | VC_HAVE_TRANSLATE_X | VC_HAVE_TRANSLATE_Y; + let mut record = vec![flags as u8, 0x00, 0x05, 0x00]; + record.extend_from_slice(&0i16.to_be_bytes()); + record.extend_from_slice(&0i16.to_be_bytes()); + let t = |coords: &[f32]| { + let c = first(&record, Some(&store), None, coords); + (c.components[0].transform[4], c.components[0].transform[5]) + }; + assert_eq!(t(&[1.0, 0.0]), (10.0, 20.0)); + assert_eq!(t(&[0.0, 1.0]), (-3.0, 5.0)); + assert_eq!(t(&[0.5, 1.0]), (2.0, 15.0)); + // A delta set that ends before it fills both tuples adds nothing. + let short = build_store( + &[&[(0, 0.0, 1.0, 1.0)], &[(1, 0.0, 1.0, 1.0)]], + &[&[0x02, 10, 20, 7]], + ); + let c = first(&record, Some(&short), None, &[1.0, 1.0]); + assert_eq!( + (c.components[0].transform[4], c.components[0].transform[5]), + (0.0, 0.0) + ); + // Extra values past the last tuple are ignored. + let long = build_store( + &[&[(0, 0.0, 1.0, 1.0)], &[(1, 0.0, 1.0, 1.0)]], + &[&[0x05, 10, 20, (-3i8) as u8, 5, 99, 99]], + ); + let c = first(&record, Some(&long), None, &[1.0, 1.0]); + assert_eq!( + (c.components[0].transform[4], c.components[0].transform[5]), + (7.0, 25.0) + ); +} + +#[test] +fn deltas_skip_the_default_instance_and_no_variation() { + // A region without axes has scalar 1 everywhere, but HarfBuzz adds + // no deltas when the font has no coords, nor for index 0xFFFFFFFF. + let store = build_store(&[&[]], &[&[0x00, 50]]); + let mut record = vec![ + (VC_TRANSFORM_HAS_VARIATION | VC_HAVE_TRANSLATE_X) as u8, + 0x00, + 0x05, + ]; + record.push(0x00); // index 0 + record.extend_from_slice(&1i16.to_be_bytes()); + assert_eq!( + first(&record, Some(&store), None, &[]).components[0].transform[4], + 1.0 + ); + assert_eq!( + first(&record, Some(&store), None, &[0.0]).components[0].transform[4], + 51.0 + ); + let mut none = vec![ + (VC_TRANSFORM_HAS_VARIATION | VC_HAVE_TRANSLATE_X) as u8, + 0x00, + 0x05, + ]; + none.extend_from_slice(&[0xF0, 0xFF, 0xFF, 0xFF, 0xFF]); + none.extend_from_slice(&1i16.to_be_bytes()); + assert_eq!( + first(&none, Some(&store), None, &[0.0]).components[0].transform[4], + 1.0 + ); +} + +#[test] +fn axis_indices_past_harfbuzz_limit_are_ignored() { + // HarfBuzz keeps at most 4096 component axes and drops a write to + // a later one. Axis 65535 used to grow the vector to 65536 coords. + let lists: &[&[u8]] = &[&[0x41, 0xFF, 0xFF, 0x00, 0x01]]; // 65535, then 1 + let mut record = vec![VC_HAVE_AXES as u8, 0x00, 0x05, 0x00]; + record.extend_from_slice(&[0x41, 0x20, 0x00, 0x10, 0x00]); // 0.5, 0.25 + let c = first(&record, None, Some(lists), &[0.1]); + assert_eq!(c.components[0].coords, [0.1, 0.25]); +} + +#[test] +fn axis_indices_index_past_the_list_names_no_axes() { + // As in HarfBuzz, an out-of-range index is an empty tuple: no axis + // values follow, and the next bytes are the next fields. + let lists: &[&[u8]] = &[&[0x00, 0x00]]; + let flags = VC_HAVE_AXES | VC_HAVE_TRANSLATE_X; + let mut record = vec![flags as u8, 0x00, 0x05, 0x07]; + record.extend_from_slice(&33i16.to_be_bytes()); + let c = first(&record, None, Some(lists), &[0.1]); + assert_eq!(c.components[0].coords, [0.1]); + assert_eq!(c.components[0].transform[4], 33.0); +} + +#[test] +fn glyph_ids_past_16_bits_draw_nothing() { + // A 24-bit glyph id past 65535 names no glyph of a 16-bit face. It + // used to wrap to a low id (0x010005 drew glyph 5). + let flags = VC_GID_IS_24BIT; + let mut record = vec![0x80 | (flags >> 8) as u8, flags as u8, 0x01, 0x00, 0x05]; + record.extend_from_slice(&[0x00, 0x00, 0x06]); // then glyph 6 + let c = first(&record, None, None, &[]); + let gids: Vec = c.components.iter().map(|c| c.gid).collect(); + assert_eq!(gids, [6]); +} + +#[test] +fn axis_indices_walk_malformed_streams_like_harfbuzz() { + assert_eq!(decode_axis_indices(&[0x00, 3, 0x81]), [3, 0, 0]); + // A run of two words with one byte left yields a zero, then the + // walk reads that byte as the next control. + assert_eq!(decode_axis_indices(&[0x41, 0x00]), [0]); + // Negative values name no axis. + assert_eq!(decode_axis_indices(&[0x00, 0xFF]), [u32::MAX]); + assert!(decode_axis_indices(&[]).is_empty()); +} From 2501ea7454d8c1c80b1f876855bb5b2ffc195fc5 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 09:16:23 -0800 Subject: [PATCH 05/13] Gate VARC components on conditions and honor RESET_UNSPECIFIED_AXES 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. --- src/tables/varc.rs | 247 ++++++++++++++++++++++++++++++++++----- src/tables/varc/tests.rs | 199 +++++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+), 28 deletions(-) diff --git a/src/tables/varc.rs b/src/tables/varc.rs index 1eb5b34b..01b913d6 100644 --- a/src/tables/varc.rs +++ b/src/tables/varc.rs @@ -18,8 +18,10 @@ //! //! sigilbuzz parses the table on demand and exposes the resolved //! component list at a given normalized coord vector via -//! [`Varc::composite`]. The actual outline flattening is the caller's -//! job. See [`crate::Face::glyph_outline_at_coords`], which delegates +//! [`Varc::composite`], or [`Varc::composite_with_font_coords`] for a +//! glyph reached through another composite, whose +//! `RESET_UNSPECIFIED_AXES` components start from the font's coords. +//! The actual outline flattening is the caller's job. See [`crate::Face::glyph_outline_at_coords`], which delegates //! to VARC when the gid is covered. //! //! # Header @@ -29,7 +31,7 @@ //! u16 minorVersion = 0 //! Offset32 coverage //! Offset32 multiVarStore -//! Offset32 conditionList (sigilbuzz parses but ignores) +//! Offset32 conditionList gates components //! Offset32 axisIndicesList CFF2 INDEX of TupleValues //! Offset32 glyphRecords CFF2 INDEX of VarCompositeGlyph //! ``` @@ -45,9 +47,7 @@ //! # Scope //! //! This module only reads VARC. Subsetting lives in the -//! `sigilbuzz-subset` crate. ConditionList parsing is stubbed (we -//! advance past it but never gate on conditions); in-the-wild VARC -//! fonts shipped to date do not exercise conditions either. +//! `sigilbuzz-subset` crate. use alloc::vec::Vec; @@ -61,6 +61,8 @@ use crate::tables::parse::{hb_roundf, Reader}; pub struct Varc<'a> { coverage: Coverage<'a>, var_store: Option>, + /// The ConditionList, from its first byte to the end of the table. + condition_list: Option<&'a [u8]>, /// One axis-indices tuple per CFF2 INDEX entry; outer index of the /// component's `axisIndicesIndex` selects one of these. axis_indices_lists: Vec>, @@ -81,7 +83,8 @@ pub struct VarcComponent { /// `(x', y') = (xx*x + xy*y + tx, yx*x + yy*y + ty)`. pub transform: [f32; 6], /// Effective normalized axis coords for the child outline, in - /// `fvar` axis order: the parent's coord vector with any HAVE_AXES + /// `fvar` axis order: the parent's coord vector (the font's when + /// the component sets `RESET_UNSPECIFIED_AXES`) with any HAVE_AXES /// values written over the listed axes. The vector grows to cover /// the highest listed axis when the parent's is shorter. Each /// written value is the component's axis value plus its deltas, @@ -97,9 +100,8 @@ pub struct VarcComposite { pub components: Vec, } -// Variable-component flag bits (per boring-expansion-spec). Bit 0, -// RESET_UNSPECIFIED_AXES, is not honored: axes a component does not -// list always keep the parent's value. +// Variable-component flag bits (per boring-expansion-spec). +const VC_RESET_UNSPECIFIED_AXES: u32 = 1 << 0; const VC_HAVE_AXES: u32 = 1 << 1; const VC_AXIS_VALUES_HAVE_VARIATION: u32 = 1 << 2; const VC_TRANSFORM_HAS_VARIATION: u32 = 1 << 3; @@ -124,6 +126,15 @@ const NO_VARIATION: u32 = 0xFFFF_FFFF; /// ignored. const MAX_COMPONENT_AXES: usize = 4096; +/// Condition tables one `composite_with_font_coords` call evaluates at +/// most. And and Or tables can share children, so a small table can +/// name a huge tree; past the budget every condition fails. +const MAX_CONDITION_TABLES: u32 = 1 << 16; + +/// HarfBuzz's `HB_MAX_NESTING_LEVEL`: its sanitizer drops a condition +/// nested deeper, which then does not hold. +const MAX_CONDITION_DEPTH: usize = 64; + /// Upper bound on the coordinate values one composite carries across /// all its components. Each component copies a coord vector, widened /// to the highest axis index it lists, so a few record bytes can ask @@ -145,7 +156,7 @@ impl<'a> Varc<'a> { } let coverage_off = r.read_u32()? as usize; let var_store_off = r.read_u32()? as usize; - let _condition_list_off = r.read_u32()? as usize; + let condition_list_off = r.read_u32()? as usize; let axis_indices_off = r.read_u32()? as usize; let glyph_records_off = r.read_u32()? as usize; @@ -181,6 +192,14 @@ impl<'a> Varc<'a> { out }; + // A ConditionList that is absent or starts past the end holds + // no conditions, so every condition reads as false, as with + // HarfBuzz's Null table. + let condition_list = match condition_list_off { + 0 => None, + off => data.get(off..), + }; + let glyph_records = if glyph_records_off == 0 { Vec::new() } else { @@ -191,6 +210,7 @@ impl<'a> Varc<'a> { Ok(Self { coverage, var_store, + condition_list, axis_indices_lists, glyph_records, }) @@ -213,19 +233,51 @@ impl<'a> Varc<'a> { /// Resolves the component list for `gid` at the given normalized /// axis coords. Returns `None` for uncovered gids. /// - /// Components are read as HarfBuzz's `decompile_record` reads them - /// and evaluated as its `VarComponent::get_path_at` does. A component - /// whose glyph id does not fit 16 bits names no glyph and is left - /// out. A malformed component ends the list early. So does a - /// component that would push the total length of all component - /// coord vectors past 2^20 values. + /// `coords` are both the glyph's coords and the font's, which is + /// right for a glyph drawn on its own. For a glyph reached through + /// another VARC composite, see [`Varc::composite_with_font_coords`]. #[must_use] pub fn composite(&self, gid: u16, coords: &[f32]) -> Option { + self.composite_with_font_coords(gid, coords, coords) + } + + /// Resolves the component list for `gid`, whose own coords are + /// `coords`, in a font set to `font_coords`. Returns `None` for + /// uncovered gids. The two differ when `gid` is a component of + /// another VARC composite: `coords` are then that component's + /// coords. + /// + /// Components are read as HarfBuzz's `decompile_record` reads them + /// and evaluated as its `VarComponent::get_path_at` does: + /// + /// - A component with a condition is left out unless the condition + /// (from the table's ConditionList, evaluated at `coords`) holds. + /// An index past the list, or a condition that is malformed, + /// nested more than 64 deep, or of an unknown format, does not + /// hold. One call evaluates at most 65536 condition tables; past + /// that every condition fails. + /// - The child's coords start from `coords`, or from `font_coords` + /// when the component sets `RESET_UNSPECIFIED_AXES` (and, as in + /// HarfBuzz, when `coords` hold more than 4096 axes); the axes the + /// component lists take its values. + /// + /// A component whose glyph id does not fit 16 bits names no glyph + /// and is left out. A malformed component ends the list early. So + /// does a component that would push the total length of all + /// component coord vectors past 2^20 values. + #[must_use] + pub fn composite_with_font_coords( + &self, + gid: u16, + coords: &[f32], + font_coords: &[f32], + ) -> Option { let idx = self.coverage.index_of(gid)? as usize; let raw = *self.glyph_records.get(idx)?; let mut composite = VarcComposite::default(); let mut r = Reader::new(raw); let mut total_coords = 0usize; + let mut condition_budget = MAX_CONDITION_TABLES; while !r.is_empty() { // A VarComponent stops when bytes run out. Reaching the // end mid-record means the font is malformed; we skip the @@ -234,7 +286,12 @@ impl<'a> Varc<'a> { let Ok(record) = self.read_component(&mut r) else { break; }; - let Some(c) = self.resolve_component(&record, coords) else { + if let Some(index) = record.condition_index { + if !self.condition_holds(index, coords, &mut condition_budget) { + continue; + } + } + let Some(c) = self.resolve_component(&record, coords, font_coords) else { continue; }; total_coords = total_coords.saturating_add(c.coords.len()); @@ -313,11 +370,13 @@ impl<'a> Varc<'a> { } /// Evaluates `record` at `coords`, the coords of the glyph the - /// component belongs to. `None` when the component draws nothing. + /// component belongs to, in a font set to `font_coords`. `None` when + /// the component draws nothing. fn resolve_component( &self, record: &ComponentRecord<'_>, coords: &[f32], + font_coords: &[f32], ) -> Option { let gid = u16::try_from(record.gid).ok()?; @@ -327,13 +386,20 @@ impl<'a> Varc<'a> { self.add_deltas(index, coords, &mut axis_values); } - // The child starts from the parent's coord vector, and the - // listed axes take the component's values. HarfBuzz keeps - // coords as whole F2DOT14 values, so each value plus its deltas - // rounds to one, halves up. It holds at most - // `MAX_COMPONENT_AXES` coords and ignores an axis index past - // them. - let mut child_coords = coords.to_vec(); + // The child starts from the parent's coord vector, or with + // RESET_UNSPECIFIED_AXES from the font's, and the listed axes + // take the component's values. HarfBuzz keeps coords as whole + // F2DOT14 values, so each value plus its deltas rounds to one, + // halves up. It holds at most `MAX_COMPONENT_AXES` coords, + // ignores an axis index past them, and starts from the font's + // coords when the parent's hold more. + let reset = record.flags & VC_RESET_UNSPECIFIED_AXES != 0; + let base = if reset || coords.len() > MAX_COMPONENT_AXES { + font_coords + } else { + coords + }; + let mut child_coords = base.to_vec(); for (&axis, &value) in record.axis_indices.iter().zip(&axis_values) { let axis = axis as usize; if axis >= MAX_COMPONENT_AXES { @@ -377,6 +443,117 @@ impl<'a> Varc<'a> { }) } + /// Whether condition `index` of the ConditionList holds at `coords`. + /// + /// ```text + /// ConditionList: u32 count, Offset32 conditions[count] + /// (from the start of the list) + /// ``` + /// + /// A missing list, an index past it, and a null or out-of-range + /// offset all name HarfBuzz's Null condition, which does not hold. + fn condition_holds(&self, index: u32, coords: &[f32], budget: &mut u32) -> bool { + let Some(list) = self.condition_list else { + return false; + }; + let Some(count) = be_u32(list, 0) else { + return false; + }; + if index >= count { + return false; + } + let slot = (index as usize) + .checked_mul(4) + .and_then(|at| at.checked_add(4)); + let Some(offset) = slot.and_then(|at| be_u32(list, at)) else { + return false; + }; + match list.get(offset as usize..) { + Some(condition) if offset != 0 => self.evaluate_condition(condition, coords, 0, budget), + _ => false, + } + } + + /// Evaluates the condition table at the start of `data`, as + /// HarfBuzz's `Condition::evaluate` does: + /// + /// ```text + /// 1 AxisRange: u16 format, u16 axisIndex, F2DOT14 min, F2DOT14 max + /// 2 Value: u16 format, i16 defaultValue, u32 varIndex + /// 3 And: u16 format, u8 count, Offset24 conditions[count] + /// 4 Or: u16 format, u8 count, Offset24 conditions[count] + /// 5 Negate: u16 format, Offset24 condition + /// ``` + /// + /// Offsets are from the start of the condition that holds them. A + /// null offset names the Null condition, which does not hold, so + /// its negation does. A table cut short, an unknown format, a table + /// nested deeper than `MAX_CONDITION_DEPTH` (HarfBuzz's sanitizer + /// drops those), and every table once `budget` runs out do not + /// hold either. + fn evaluate_condition( + &self, + data: &[u8], + coords: &[f32], + depth: usize, + budget: &mut u32, + ) -> bool { + if depth >= MAX_CONDITION_DEPTH || *budget == 0 { + return false; + } + *budget -= 1; + let child = |offset: usize, budget: &mut u32| -> bool { + let Some(at) = be_u24(data, offset) else { + return false; + }; + match data.get(at..) { + Some(child) if at != 0 => self.evaluate_condition(child, coords, depth + 1, budget), + _ => false, + } + }; + match be_u16(data, 0) { + Some(1) => { + let (Some(axis), Some(min), Some(max)) = + (be_u16(data, 2), be_u16(data, 4), be_u16(data, 6)) + else { + return false; + }; + // HarfBuzz compares whole F2DOT14 values; an axis the + // coords do not reach is at its default. + let coord = coords + .get(usize::from(axis)) + .map_or(0.0, |&c| hb_roundf(c * 16384.0)); + f32::from(min as i16) <= coord && coord <= f32::from(max as i16) + } + Some(2) => { + let (Some(default), Some(index)) = (be_u16(data, 2), be_u32(data, 4)) else { + return false; + }; + let mut value = [f32::from(default as i16)]; + self.add_deltas(index, coords, &mut value); + value[0] > 0.0 + } + Some(format @ (3 | 4)) => { + let Some(&count) = data.get(2) else { + return false; + }; + // The whole offset array has to be there, as for + // HarfBuzz's sanitizer. + if data.len() < 3 + 3 * usize::from(count) { + return false; + } + let mut offsets = (0..usize::from(count)).map(|i| 3 + 3 * i); + if format == 3 { + offsets.all(|at| child(at, budget)) + } else { + offsets.any(|at| child(at, budget)) + } + } + Some(5) => data.len() >= 5 && !child(2, budget), + _ => false, + } + } + /// Adds the deltas of the variation index `index` at `coords` to /// `values`. Nothing is added at the default instance (empty /// coords), for `NO_VARIATION`, or without a store, as in HarfBuzz. @@ -396,8 +573,6 @@ struct ComponentRecord<'t> { flags: u32, /// 16- or 24-bit glyph id. gid: u32, - /// Read for HAVE_CONDITION; conditions are not evaluated yet. - #[allow(dead_code)] condition_index: Option, /// The axes the component sets, from the axis indices list. axis_indices: &'t [u32], @@ -488,6 +663,22 @@ enum TransformField { TCenterY, } +/// Big-endian `u16` at byte `at` of `data`. +fn be_u16(data: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(*data.get(at..)?.first_chunk::<2>()?)) +} + +/// Big-endian 24-bit offset at byte `at` of `data`. +fn be_u24(data: &[u8], at: usize) -> Option { + let [a, b, c] = *data.get(at..)?.first_chunk::<3>()?; + Some((usize::from(a) << 16) | (usize::from(b) << 8) | usize::from(c)) +} + +/// Big-endian `u32` at byte `at` of `data`. +fn be_u32(data: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(*data.get(at..)?.first_chunk::<4>()?)) +} + /// Variable-length integer encoding used by VARC. 1-5 bytes /// big-endian, sign-extension-free. fn read_uint32var(r: &mut Reader<'_>) -> Result { diff --git a/src/tables/varc/tests.rs b/src/tables/varc/tests.rs index 99f1f32d..a51f6e14 100644 --- a/src/tables/varc/tests.rs +++ b/src/tables/varc/tests.rs @@ -602,3 +602,202 @@ fn axis_indices_walk_malformed_streams_like_harfbuzz() { assert_eq!(decode_axis_indices(&[0x00, 0xFF]), [u32::MAX]); assert!(decode_axis_indices(&[]).is_empty()); } + +/// `table` with `conditions` appended as its ConditionList. +fn with_conditions(mut table: Vec, conditions: &[u8]) -> Vec { + let at = table.len() as u32; + table[12..16].copy_from_slice(&at.to_be_bytes()); + table.extend_from_slice(conditions); + table +} + +fn f2dot14(v: f32) -> [u8; 2] { + ((v * 16384.0) as i16).to_be_bytes() +} + +fn cond_axis(axis: u16, min: f32, max: f32) -> Vec { + let mut out = vec![0, 1]; + out.extend_from_slice(&axis.to_be_bytes()); + out.extend_from_slice(&f2dot14(min)); + out.extend_from_slice(&f2dot14(max)); + out +} + +/// And (3) or Or (4) over `children`, laid out after the offsets. +fn cond_op(format: u8, children: &[Vec]) -> Vec { + let mut out = vec![0, format, children.len() as u8]; + let mut at = 3 + 3 * children.len(); + for c in children { + out.extend_from_slice(&(at as u32).to_be_bytes()[1..]); + at += c.len(); + } + for c in children { + out.extend_from_slice(c); + } + out +} + +fn cond_not(child: Option<&[u8]>) -> Vec { + let mut out = vec![0, 5, 0, 0, if child.is_some() { 5 } else { 0 }]; + out.extend_from_slice(child.unwrap_or_default()); + out +} + +fn condition_list(conditions: &[Vec]) -> Vec { + let mut out = (conditions.len() as u32).to_be_bytes().to_vec(); + let mut at = 4 + 4 * conditions.len(); + for c in conditions { + out.extend_from_slice(&(at as u32).to_be_bytes()); + at += c.len(); + } + for c in conditions { + out.extend_from_slice(c); + } + out +} + +/// A component of glyph `gid` gated by condition `index`. +fn gated(gid: u16, index: u8) -> Vec { + // HAVE_CONDITION is 0x80, which takes the two-byte uint32var form. + let mut out = vec![0x80, VC_HAVE_CONDITION as u8]; + out.extend_from_slice(&gid.to_be_bytes()); + out.push(index); + out +} + +#[test] +fn conditions_gate_components() { + let store = build_store(&[&[(0, 0.0, 1.0, 1.0)]], &[&[0x00, 2]]); + let mut value = vec![0, 2]; + value.extend_from_slice(&(-1i16).to_be_bytes()); + value.extend_from_slice(&0u32.to_be_bytes()); // -1 + 2 at axis 0 = +1 + let conditions = condition_list(&[ + cond_axis(0, 0.25, 1.0), + value, + cond_op( + 3, + &[ + cond_axis(1, -1.0, 0.0), + cond_not(Some(&cond_axis(0, 0.5, 1.0))), + ], + ), + cond_op(4, &[cond_axis(0, -1.0, -0.5), cond_axis(1, 0.5, 1.0)]), + cond_not(None), + vec![0, 9, 0, 0], + vec![0, 3, 3, 0, 0, 6], + ]); + let mut record = Vec::new(); + for i in 0..7u8 { + record.extend_from_slice(&gated(10 + u16::from(i), i)); + } + record.extend_from_slice(&gated(99, 99)); + record.extend_from_slice(&[0x00, 0x00, 0x05]); // ungated glyph 5 + let table = with_conditions( + build_varc(&[1], &[&record], Some(&store), None), + &conditions, + ); + let varc = Varc::parse(&table).unwrap(); + let shown = |coords: &[f32]| -> Vec { + let c = varc.composite(1, coords).unwrap(); + c.components.iter().map(|c| c.gid).collect() + }; + // 10: axis range; 11: value plus delta; 12: And with Negate; 13: + // Or; 14: Negate of the Null condition; 15: unknown format; 16: And + // cut short; 99: past the list. + assert_eq!(shown(&[]), [12, 14, 5]); + assert_eq!(shown(&[0.0, 0.0]), [12, 14, 5]); + assert_eq!(shown(&[0.25, 0.0]), [10, 12, 14, 5]); + assert_eq!(shown(&[0.75, -0.5]), [10, 11, 14, 5]); + assert_eq!(shown(&[0.75, 0.5]), [10, 11, 13, 14, 5]); + assert_eq!(shown(&[-0.5, 0.0]), [12, 13, 14, 5]); + // A coord off the F2DOT14 grid compares after rounding. + assert_eq!(shown(&[0.25 - 1.0 / 65536.0, 0.0]), [10, 12, 14, 5]); +} + +#[test] +fn conditions_nested_past_64_levels_fail() { + // A chain of And tables with one child each, ending in a condition + // that holds. HarfBuzz's sanitizer drops a chain deeper than 64. + let chain = |levels: usize| { + let mut c = cond_axis(0, -1.0, 1.0); + for _ in 0..levels { + c = cond_op(3, &[c]); + } + let table = with_conditions( + build_varc(&[1], &[&gated(10, 0)], None, None), + &condition_list(&[c]), + ); + let varc = Varc::parse(&table).unwrap(); + varc.composite(1, &[0.0]).unwrap().components.len() + }; + assert_eq!(chain(60), 1); + assert_eq!(chain(64), 0); + assert_eq!(chain(70), 0); +} + +#[test] +fn shared_condition_trees_stop_at_the_budget() { + // Five levels of And tables whose 255 offsets all name the next + // level: 255^5 visits without a budget. Every leaf holds, so only + // the budget can stop the walk, and the component is left out. + let level_len = 3 + 3 * 255; + let mut conditions = Vec::new(); + for _ in 0..5 { + conditions.extend_from_slice(&[0, 3, 255]); + for _ in 0..255 { + conditions.extend_from_slice(&(level_len as u32).to_be_bytes()[1..]); + } + } + conditions.extend_from_slice(&cond_axis(0, -1.0, 1.0)); + let table = with_conditions( + build_varc(&[1], &[&gated(10, 0)], None, None), + &condition_list(&[conditions]), + ); + let varc = Varc::parse(&table).unwrap(); + assert!(varc.composite(1, &[0.0]).unwrap().components.is_empty()); +} + +#[test] +fn reset_unspecified_axes_starts_from_the_font_coords() { + // Two components setting axis 0 to 0.5, one with + // RESET_UNSPECIFIED_AXES, and a third that resets without axes. + let lists: &[&[u8]] = &[&[0x00, 0x00]]; + let mut record = Vec::new(); + for flags in [VC_HAVE_AXES, VC_HAVE_AXES | VC_RESET_UNSPECIFIED_AXES] { + record.extend_from_slice(&[flags as u8, 0x00, 0x05, 0x00, 0x40, 0x20, 0x00]); + } + record.extend_from_slice(&[VC_RESET_UNSPECIFIED_AXES as u8, 0x00, 0x06]); + let table = build_varc(&[1], &[&record], None, Some(lists)); + let varc = Varc::parse(&table).unwrap(); + let coords = |c: &VarcComposite| -> Vec> { + c.components.iter().map(|c| c.coords.clone()).collect() + }; + let nested = varc + .composite_with_font_coords(1, &[0.7, 0.3], &[0.1, 0.2]) + .unwrap(); + assert_eq!( + coords(&nested), + [vec![0.5, 0.3], vec![0.5, 0.2], vec![0.1, 0.2]] + ); + // For a glyph drawn on its own the font's coords are its own. + let top = varc.composite(1, &[0.7, 0.3]).unwrap(); + assert_eq!( + coords(&top), + [vec![0.5, 0.3], vec![0.5, 0.3], vec![0.7, 0.3]] + ); +} + +#[test] +fn coords_past_harfbuzz_limit_start_from_the_font_coords() { + // HarfBuzz copies at most 4096 coords, and starts a component of a + // glyph with more from the font's coords. + let table = build_varc(&[1], &[&[0x00, 0x00, 0x05]], None, None); + let varc = Varc::parse(&table).unwrap(); + let wide = vec![0.5; 4097]; + let c = varc.composite_with_font_coords(1, &wide, &[0.25]).unwrap(); + assert_eq!(c.components[0].coords, [0.25]); + let c = varc + .composite_with_font_coords(1, &wide[..4096], &[0.25]) + .unwrap(); + assert_eq!(c.components[0].coords.len(), 4096); +} From 47f4dabc656c2c3f6f87d42cf45862f84af7d46b Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 09:27:19 -0800 Subject: [PATCH 06/13] Read morx contextual substitution tables as an unsized offset array 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. --- src/tables/morx/contextual.rs | 75 ++++++++++++++---------------- src/tables/morx/tests.rs | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 40 deletions(-) diff --git a/src/tables/morx/contextual.rs b/src/tables/morx/contextual.rs index a349f5a1..3572bd50 100644 --- a/src/tables/morx/contextual.rs +++ b/src/tables/morx/contextual.rs @@ -6,6 +6,13 @@ use crate::tables::layout::state_table::{lookup_class, StateTableHeader, CLASS_O // --- Type 1: Contextual glyph substitution --- +/// Runs a contextual substitution subtable over `glyphs` the way +/// HarfBuzz's `ContextualSubtable` does. Each entry may substitute the +/// marked glyph and the current glyph through the lookups its indices +/// name. The mark starts on the first glyph, so a mark substitution +/// before any SetMark replaces glyph 0. At the end of text nothing is +/// substituted unless a mark was set, and the current substitution then +/// applies to the last glyph. pub(super) fn apply_contextual( state: &StateTableHeader<'_>, substitutions: &[u8], @@ -13,7 +20,8 @@ pub(super) fn apply_contextual( ) { const ENTRY_SIZE: usize = 8; // newState + flags + markIdx + currentIdx let mut cur_state: u16 = 0; - let mut mark: Option = None; + let mut mark: usize = 0; + let mut mark_set = false; let mut i = 0; let max_iters = max_steps(glyphs.len()); let mut iters = 0usize; @@ -36,23 +44,29 @@ pub(super) fn apply_contextual( .entry_tail_u16(entry_idx, ENTRY_SIZE, 6) .unwrap_or(0xFFFF); - if mark_idx != 0xFFFF { - if let Some(slot) = mark.and_then(|m| glyphs.get_mut(m)) { - if let Some(replacement) = sub_lookup(substitutions, mark_idx, *slot) { - *slot = replacement; + // CoreText applies neither substitution at the end of text + // unless a mark was set, and HarfBuzz follows it. + if i < glyphs.len() || mark_set { + if mark_idx != 0xFFFF { + if let Some(slot) = glyphs.get_mut(mark) { + if let Some(replacement) = sub_lookup(substitutions, mark_idx, *slot) { + *slot = replacement; + } } } - } - if cur_idx != 0xFFFF { - if let Some(slot) = glyphs.get_mut(i) { - if let Some(replacement) = sub_lookup(substitutions, cur_idx, *slot) { - *slot = replacement; + if cur_idx != 0xFFFF { + // At the end of text the current glyph is the last one. + let at = i.min(glyphs.len().saturating_sub(1)); + if let Some(slot) = glyphs.get_mut(at) { + if let Some(replacement) = sub_lookup(substitutions, cur_idx, *slot) { + *slot = replacement; + } } } - } - - if flags & FLAG_CTX_SET_MARK != 0 { - mark = Some(i); + if flags & FLAG_CTX_SET_MARK != 0 { + mark_set = true; + mark = i; + } } cur_state = new_state; if flags & FLAG_DONT_ADVANCE == 0 { @@ -63,36 +77,17 @@ pub(super) fn apply_contextual( } } -// Each "substitution lookup" referenced by index is itself an AAT -// lookup table; the substitutions blob is a sequence of such tables -// indexed by u32 offsets at its head. -// -// Layout: u16 lookupCount, then u32 offsets[lookupCount] pointing at -// the individual lookups relative to the substitutions blob. +// The substitution table is an unsized array of u32 offsets, one per +// lookup, each from the start of the table, as in the spec and +// HarfBuzz's `UnsizedListOfOffset16To, HBUINT32>`. +// Entries name lookups by index; the table itself does not say how +// many there are. // // Each lookup maps a glyph to its replacement glyph id directly // through the shared AAT lookup reader. fn sub_lookup(substitutions: &[u8], idx: u16, glyph: u16) -> Option { - // The substitutions table is laid out as in the type-1 spec: - // u16 nTables, u32 offsets[nTables] (relative to substitutions - // blob start). A missing or malformed entry yields None. - if substitutions.len() < 2 { - return None; - } - let n_tables = u16::from_be_bytes([substitutions[0], substitutions[1]]); - if idx >= n_tables { - return None; - } - let off_base = 2 + idx as usize * 4; - if substitutions.len() < off_base + 4 { - return None; - } - let off = u32::from_be_bytes([ - substitutions[off_base], - substitutions[off_base + 1], - substitutions[off_base + 2], - substitutions[off_base + 3], - ]) as usize; + let at = usize::from(idx) * 4; + let off = u32::from_be_bytes(*substitutions.get(at..)?.first_chunk::<4>()?) as usize; let lookup = substitutions.get(off..)?; // Reuse the class-lookup machinery: class value == replacement // glyph id; out-of-bounds yields the reserved class, which we diff --git a/src/tables/morx/tests.rs b/src/tables/morx/tests.rs index 9a712b7d..777d4eae 100644 --- a/src/tables/morx/tests.rs +++ b/src/tables/morx/tests.rs @@ -659,3 +659,89 @@ fn morx_chained_insertions_stay_bounded() { assert_eq!(out.len(), origins.len()); assert!(out.len() <= MAX_LEN_MIN, "run grew to {}", out.len()); } + +// ----------------------------------------------------------------- +// Type 1: contextual substitution, as HarfBuzz runs it. Every glyph +// of these runs falls in the out-of-bounds class (1). +// ----------------------------------------------------------------- + +/// Contextual entry `(newState, flags, markIndex, currentIndex)`. +fn ctx_entry(new_state: u16, flags: u16, mark: u16, current: u16) -> Vec { + [new_state, flags, mark, current] + .iter() + .flat_map(|v| v.to_be_bytes()) + .collect() +} + +/// A substitution table: an unsized array of u32 offsets from its +/// start, one per lookup, then the lookups. +fn substitution_table(lookups: &[Vec]) -> Vec { + let mut out = Vec::new(); + let mut at = 4 * lookups.len(); + for l in lookups { + out.extend_from_slice(&(at as u32).to_be_bytes()); + at += l.len(); + } + for l in lookups { + out.extend_from_slice(l); + } + out +} + +fn contextual_morx(states: &[[u16; 4]], entries: &[Vec], lookups: &[Vec]) -> Vec { + let refs: Vec<&[u8]> = entries.iter().map(Vec::as_slice).collect(); + let table = substitution_table(lookups); + let body = state_body(1, states, &refs, &[&table]); + wrap_in_chain(&[&subtable(TYPE_CONTEXTUAL, &body)]) +} + +#[test] +fn contextual_substitution_table_is_an_unsized_offset_array() { + // Two lookups. The table starts with their offsets, 8 and 8 plus + // the first lookup, with no count before them; reading a u16 + // count there found 0 tables and substituted nothing. + let lookups = [ + build_lookup_format6(&[(5, 50)]), + build_lookup_format6(&[(5, 51), (6, 61)]), + ]; + for (index, expected) in [(0u16, [50, 6]), (1, [51, 61])] { + let entries = [ctx_entry(0, 0, 0xFFFF, index)]; + let bytes = contextual_morx(&[[0, 0, 0, 0]], &entries, &lookups); + let m = Morx::parse(&bytes).unwrap(); + assert_eq!(m.apply(&[5, 6]).0, expected, "lookup {index}"); + } + // An index past the offsets substitutes nothing. + let entries = [ctx_entry(0, 0, 0xFFFF, 9)]; + let bytes = contextual_morx(&[[0, 0, 0, 0]], &entries, &lookups); + assert_eq!(Morx::parse(&bytes).unwrap().apply(&[5, 6]).0, [5, 6]); +} + +#[test] +fn contextual_end_of_text_substitutes_only_after_a_mark() { + // A glyph takes entry 1 to state 1, whose end-of-text entry (2) + // substitutes the current glyph. HarfBuzz (after CoreText) does + // that only when a mark was set, and then on the last glyph. + let lookups = [build_lookup_format6(&[(5, 50), (6, 60)])]; + let states = [[0, 1, 0, 0], [2, 1, 0, 0]]; + for (mark_flag, expected) in [(0, [5, 6]), (FLAG_CTX_SET_MARK, [5, 60])] { + let entries = [ + ctx_entry(0, 0, 0xFFFF, 0xFFFF), + ctx_entry(1, mark_flag, 0xFFFF, 0xFFFF), + ctx_entry(0, 0, 0xFFFF, 0), + ]; + let bytes = contextual_morx(&states, &entries, &lookups); + let m = Morx::parse(&bytes).unwrap(); + assert_eq!(m.apply(&[5, 6]).0, expected, "mark flag {mark_flag:#x}"); + } +} + +#[test] +fn contextual_mark_starts_on_the_first_glyph() { + // A mark substitution before any SetMark replaces glyph 0, as in + // HarfBuzz, where the mark index starts at zero. + let lookups = [build_lookup_format6(&[(5, 50)])]; + let entries = [ctx_entry(0, 0, 0, 0xFFFF)]; + let bytes = contextual_morx(&[[0, 0, 0, 0]], &entries, &lookups); + let m = Morx::parse(&bytes).unwrap(); + assert_eq!(m.apply(&[5, 5, 5]).0, [50, 5, 5]); +} From 1d43fa768a1745e78bd28bbcac556eb5dd3d60a9 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 09:32:30 -0800 Subject: [PATCH 07/13] Form morx ligatures as HarfBuzz does, a component set twice once 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. --- src/tables/morx.rs | 16 ++- src/tables/morx/ligature.rs | 224 +++++++++++++++++++++--------------- src/tables/morx/tests.rs | 168 +++++++++++++++++++++++++++ 3 files changed, 313 insertions(+), 95 deletions(-) diff --git a/src/tables/morx.rs b/src/tables/morx.rs index 84b4ad35..89cce635 100644 --- a/src/tables/morx.rs +++ b/src/tables/morx.rs @@ -79,7 +79,7 @@ use crate::tables::parse::Reader; use contextual::apply_contextual; use insertion::apply_insertion; -use ligature::apply_ligature; +use ligature::{apply_ligature, remove_deleted}; use non_contextual::apply_non_contextual; use rearrangement::apply_rearrangement; @@ -223,6 +223,10 @@ const LIG_ACTION_STORE: u32 = 1 << 30; const LIG_ACTION_OFFSET_SIGN: u32 = 1 << 29; const LIG_ACTION_OFFSET_MASK: u32 = 0x3FFF_FFFF; +/// A glyph a ligature absorbed. It stays in the run, in the deleted-glyph +/// class, until every chain has run. +const DELETED_GLYPH: u16 = 0xFFFF; + impl<'a> Morx<'a> { /// Parses a `morx` table. The data slice starts at the table's /// first byte (`version`). @@ -351,6 +355,10 @@ impl<'a> Morx<'a> { /// The returned vector is the new glyph id stream; it is always /// the same length as the mapping vector. /// + /// Components a ligature absorbs stay in the run, in the + /// deleted-glyph class, until every chain has run, and are removed + /// then, as HarfBuzz removes them after `morx`. + /// /// Insertion subtables stop inserting once the run would exceed /// eight times the input length (at least 1024 glyphs), and each /// subtable walk stops after eight state-machine steps per glyph. @@ -370,6 +378,10 @@ impl<'a> Morx<'a> { apply_subtable(body, &mut glyphs, &mut origins, max_len); } } + // HarfBuzz removes the glyphs ligatures deleted once morx is + // done, so later subtables still see them, in the deleted-glyph + // class. + remove_deleted(&mut glyphs, &mut origins); (glyphs, origins) } } @@ -519,7 +531,7 @@ fn apply_subtable( fn class_for(state: &StateTableHeader<'_>, glyph: Option) -> Result { match glyph { None => Ok(CLASS_END_OF_TEXT), - Some(0xFFFF) => Ok(CLASS_DELETED_GLYPH), + Some(DELETED_GLYPH) => Ok(CLASS_DELETED_GLYPH), Some(g) => state.class_of(g), } } diff --git a/src/tables/morx/ligature.rs b/src/tables/morx/ligature.rs index 65bcd654..afdf3c88 100644 --- a/src/tables/morx/ligature.rs +++ b/src/tables/morx/ligature.rs @@ -3,27 +3,66 @@ use alloc::vec::Vec; use super::{ - class_for, max_steps, FLAG_DONT_ADVANCE, FLAG_LIG_PERFORM_ACTION, FLAG_LIG_SET_COMPONENT, - LIG_ACTION_LAST, LIG_ACTION_OFFSET_MASK, LIG_ACTION_OFFSET_SIGN, LIG_ACTION_STORE, + class_for, max_steps, DELETED_GLYPH, FLAG_DONT_ADVANCE, FLAG_LIG_PERFORM_ACTION, + FLAG_LIG_SET_COMPONENT, LIG_ACTION_LAST, LIG_ACTION_OFFSET_MASK, LIG_ACTION_OFFSET_SIGN, + LIG_ACTION_STORE, }; use crate::tables::layout::state_table::{StateTableHeader, CLASS_OUT_OF_BOUNDS}; +/// Component positions the stack holds, HarfBuzz's +/// `HB_MAX_CONTEXT_LENGTH`. Like HarfBuzz's `match_positions`, the +/// stack is a ring: a push past this many overwrites the oldest entry. +const STACK_SIZE: usize = 64; + +/// The component stack of a ligature subtable pass: glyph positions in +/// push order, kept as HarfBuzz keeps `match_positions` and +/// `match_length`. +struct ComponentStack { + positions: [usize; STACK_SIZE], + /// Pushes not yet consumed. Can pass [`STACK_SIZE`]; positions + /// wrap. + len: usize, +} + +impl ComponentStack { + fn at(&self, index: usize) -> usize { + self.positions[index % STACK_SIZE] + } + + /// Pushes position `i`, unless it is already on top: an entry with + /// DontAdvance can set the same glyph as a component again, and + /// HarfBuzz never marks one index twice. + fn push(&mut self, i: usize) { + if self.len > 0 && self.at(self.len - 1) == i { + self.len -= 1; + } + self.positions[self.len % STACK_SIZE] = i; + self.len += 1; + } +} + // --- Type 2: Ligature substitution --- +/// Runs a ligature subtable over `glyphs` the way HarfBuzz's +/// `LigatureSubtable` does. Components that a ligature absorbs become +/// [`DELETED_GLYPH`] in place, so positions stay put for the rest of +/// the pass; [`super::Morx::apply`] removes them after the last chain, +/// as HarfBuzz removes deleted glyphs after `morx`. pub(super) fn apply_ligature( state: &StateTableHeader<'_>, lig_actions: &[u8], components: &[u8], ligatures: &[u8], - glyphs: &mut Vec, - origins: &mut Vec, + glyphs: &mut [u16], + origins: &mut [usize], ) { const ENTRY_SIZE: usize = 6; // newState + flags + actionIndex let mut cur_state: u16 = 0; - let mut component_stack: Vec = Vec::new(); + let mut stack = ComponentStack { + positions: [0; STACK_SIZE], + len: 0, + }; let mut i = 0; - // The step cap also bounds the component stack, since each step - // pushes at most one entry. let max_iters = max_steps(glyphs.len()); let mut iters = 0usize; while i <= glyphs.len() { @@ -40,20 +79,18 @@ pub(super) fn apply_ligature( }; let action_idx = state.entry_tail_u16(entry_idx, ENTRY_SIZE, 4).unwrap_or(0); - if flags & FLAG_LIG_SET_COMPONENT != 0 && i < glyphs.len() { - component_stack.push(i); + if flags & FLAG_LIG_SET_COMPONENT != 0 { + stack.push(i); } - if flags & FLAG_LIG_PERFORM_ACTION != 0 && !component_stack.is_empty() { - perform_ligature_action( - action_idx, + // HarfBuzz performs no action with an empty stack or at the end + // of text. + if flags & FLAG_LIG_PERFORM_ACTION != 0 && stack.len > 0 && i < glyphs.len() { + let tables = ActionTables { lig_actions, components, ligatures, - &mut component_stack, - glyphs, - origins, - &mut i, - ); + }; + perform_ligature_action(action_idx, &tables, &mut stack, glyphs, origins); } cur_state = new_state; @@ -65,100 +102,91 @@ pub(super) fn apply_ligature( } } -#[allow(clippy::too_many_arguments)] +/// The three arrays a ligature action reads. +struct ActionTables<'t> { + lig_actions: &'t [u8], + components: &'t [u8], + ligatures: &'t [u8], +} + +/// Runs the action list that starts at `action_idx`, as HarfBuzz's +/// `LigatureSubtable::driver_context_t::transition` does. Each action +/// pops a component off the stack and adds `components[glyph + offset]` +/// to the ligature index. An action with Store or Last puts +/// `ligatures[index]` in place of the component it popped, deletes the +/// components popped since the last store, and leaves the ligature on +/// the stack for later actions. The index carries on across stores. +/// Running out of components clears the stack; reading past an array +/// ends the list. fn perform_ligature_action( action_idx: u16, - lig_actions: &[u8], - components: &[u8], - ligatures: &[u8], - stack: &mut Vec, - glyphs: &mut Vec, - origins: &mut Vec, - cursor: &mut usize, + tables: &ActionTables<'_>, + stack: &mut ComponentStack, + glyphs: &mut [u16], + origins: &mut [usize], ) { - // Walk action entries starting at `action_idx`, summing - // component-table lookups into a component-index offset. The - // `LAST` bit ends the walk; on the final action, if `STORE` is - // set, the offset is used to index the ligature table and emit - // the resulting glyph id. - let mut offset: i32 = 0; - let mut action_pos = action_idx as usize; - let mut consumed: Vec = Vec::new(); + let mut cursor = stack.len; + let mut action_pos = usize::from(action_idx); + let mut ligature_idx: u32 = 0; loop { - let Some(stack_top) = stack.pop() else { + if cursor == 0 { + // Stack underflow: clear the stack. + stack.len = 0; + return; + } + cursor -= 1; + let pos = stack.at(cursor); + let Some(&glyph) = glyphs.get(pos) else { return; }; - consumed.push(stack_top); - - let Some(action) = u32_at(lig_actions, action_pos) else { + let Some(action) = u32_at(tables.lig_actions, action_pos) else { return; }; - let raw_off = action & LIG_ACTION_OFFSET_MASK; - // Sign-extend from the 30-bit signed offset field to i32. The - // `as` casts wrap on purpose: that is the conversion. - let signed_off: i32 = if action & LIG_ACTION_OFFSET_SIGN != 0 { - (raw_off | 0xC000_0000) as i32 + // Sign-extend the 30-bit offset. HarfBuzz adds it to the glyph + // id in unsigned arithmetic, so a negative sum wraps far past + // the end of the component array. + let offset = if raw_off & LIG_ACTION_OFFSET_SIGN != 0 { + raw_off | 0xC000_0000 } else { - raw_off as i32 + raw_off }; - // An earlier ligature in this walk removes glyphs but leaves - // the stack as is, so a stack entry can point past the run. - // Stop the action instead of reading out of bounds. - let Some(&glyph) = glyphs.get(stack_top) else { - return; - }; - // Cannot overflow: the glyph is at most 0xFFFF and the offset - // is a 30-bit signed value. - let comp_idx = i32::from(glyph) + signed_off; - // A negative index points before the component table. Treat - // it like any other out-of-range read. - let Some(comp_val) = usize::try_from(comp_idx) + let component_idx = u32::from(glyph).wrapping_add(offset); + let Some(component) = usize::try_from(component_idx) .ok() - .and_then(|idx| u16_at(components, idx)) + .and_then(|idx| u16_at(tables.components, idx)) else { return; }; - offset = offset.wrapping_add(i32::from(comp_val)); + ligature_idx = ligature_idx.wrapping_add(u32::from(component)); - if action & LIG_ACTION_LAST != 0 { - if action & LIG_ACTION_STORE != 0 { - let lig_glyph = usize::try_from(offset) - .ok() - .and_then(|idx| u16_at(ligatures, idx)); - if let Some(lig_glyph) = lig_glyph { - // Replace the earliest consumed slot with the - // ligature, drop the later slots. Sort in - // ascending order so the earliest index lands - // first. Stack was LIFO so the natural order is - // reversed. - consumed.sort_unstable(); - let Some((&keep, rest)) = consumed.split_first() else { - return; - }; - if let Some(slot) = glyphs.get_mut(keep) { - *slot = lig_glyph; - } - // origins[keep] keeps the smallest originating - // input index so cluster merging finds the - // correct grapheme root. - // Remove every other consumed slot, highest index - // first so earlier indices stay valid. A glyph - // pushed twice shows up twice here, so an earlier - // removal can shorten the run past a later index. - // Skip those. - for &idx in rest.iter().rev() { - if idx >= glyphs.len().min(origins.len()) { - continue; - } - glyphs.remove(idx); - origins.remove(idx); - if idx < *cursor { - *cursor -= 1; - } - } + if action & (LIG_ACTION_STORE | LIG_ACTION_LAST) != 0 { + let Some(lig) = usize::try_from(ligature_idx) + .ok() + .and_then(|idx| u16_at(tables.ligatures, idx)) + else { + return; + }; + glyphs[pos] = lig; + let lig_end = stack.at(stack.len - 1) + 1; + // Delete the components popped since the last store. + while stack.len - 1 > cursor { + stack.len -= 1; + let Some(slot) = glyphs.get_mut(stack.at(stack.len)) else { + return; + }; + *slot = DELETED_GLYPH; + } + // HarfBuzz merges the clusters from the ligature to the last + // component into one; the ligature takes the earliest + // origin among them. + if let Some(span) = origins.get(pos..lig_end.min(origins.len())) { + if let Some(&first) = span.iter().min() { + origins[pos] = first; } } + } + if action & LIG_ACTION_LAST != 0 { return; } action_pos += 1; @@ -178,3 +206,13 @@ fn u32_at(data: &[u8], index: usize) -> Option { let bytes = data.get(start..)?.first_chunk::<4>()?; Some(u32::from_be_bytes(*bytes)) } + +/// Removes every [`DELETED_GLYPH`] and its origin. +pub(super) fn remove_deleted(glyphs: &mut Vec, origins: &mut Vec) { + if !glyphs.contains(&DELETED_GLYPH) { + return; + } + let mut keep = glyphs.iter().map(|&g| g != DELETED_GLYPH); + origins.retain(|_| keep.next().unwrap_or(true)); + glyphs.retain(|&g| g != DELETED_GLYPH); +} diff --git a/src/tables/morx/tests.rs b/src/tables/morx/tests.rs index 777d4eae..c66b0098 100644 --- a/src/tables/morx/tests.rs +++ b/src/tables/morx/tests.rs @@ -745,3 +745,171 @@ fn contextual_mark_starts_on_the_first_glyph() { let m = Morx::parse(&bytes).unwrap(); assert_eq!(m.apply(&[5, 5, 5]).0, [50, 5, 5]); } + +// ----------------------------------------------------------------- +// Type 2: ligatures, as HarfBuzz forms them. Every glyph of these +// runs falls in the out-of-bounds class (1), so the states alone +// drive the walk. +// ----------------------------------------------------------------- + +/// Ligature entry with an explicit action index. +fn lig_entry_at(new_state: u16, flags: u16, action: u16) -> Vec { + [new_state, flags, action] + .iter() + .flat_map(|v| v.to_be_bytes()) + .collect() +} + +/// A ligature subtable over `states` and `entries`, with action words +/// `actions`, `components[index] = value` for each pair, and the +/// ligature list `ligatures`. +fn ligature_morx( + states: &[[u16; 4]], + entries: &[Vec], + actions: &[u32], + components: &[(usize, u16)], + ligatures: &[u16], +) -> Vec { + let refs: Vec<&[u8]> = entries.iter().map(Vec::as_slice).collect(); + let actions: Vec = actions.iter().flat_map(|a| a.to_be_bytes()).collect(); + let mut comps = vec![0u16; 256]; + for &(i, v) in components { + comps[i] = v; + } + let comps: Vec = comps.iter().flat_map(|c| c.to_be_bytes()).collect(); + let ligs: Vec = ligatures.iter().flat_map(|g| g.to_be_bytes()).collect(); + let body = state_body(3, states, &refs, &[&actions, &comps, &ligs]); + wrap_in_chain(&[&subtable(TYPE_LIGATURE, &body)]) +} + +const LS: u32 = LIG_ACTION_LAST | LIG_ACTION_STORE; +const PUSH: u16 = FLAG_LIG_SET_COMPONENT; +const ACT: u16 = FLAG_LIG_PERFORM_ACTION; + +#[test] +fn ligature_component_set_twice_counts_once() { + // Glyph 1 is set as a component, kept with DontAdvance, and set + // again with the action. HarfBuzz never pushes one index twice, so + // the action pops glyph 1 and glyph 0 and forms the ligature. With + // the double push it popped glyph 1 twice, put the ligature there, + // and then removed it as the duplicate: [5, 6] came out as [5]. + let entries = [ + lig_entry_at(0, 0, 0), + lig_entry_at(1, PUSH, 0), + lig_entry_at(2, PUSH | STAY, 0), + lig_entry_at(0, PUSH | ACT, 0), + ]; + let states = [[0, 1, 0, 0], [0, 2, 0, 0], [0, 3, 0, 0]]; + // comp[6] + comp[5] = 3: ligatures[3]. + let bytes = ligature_morx(&states, &entries, &[0, LS], &[(6, 3)], &[0, 0, 0, 99]); + let m = Morx::parse(&bytes).unwrap(); + assert_eq!(m.apply(&[5, 6]), (vec![99], vec![0])); + assert_eq!(m.apply(&[5, 6, 5, 6]), (vec![99, 99], vec![0, 2])); +} + +#[test] +fn ligature_stays_on_the_stack_for_the_next_action() { + // [5, 6] forms ligature 90 at position 0, which stays on the stack. + // Glyph 7 then joins it into 91. The stack used to lose the + // ligature, so the second action ran out of components. + let entries = [ + lig_entry_at(0, 0, 0), + lig_entry_at(1, PUSH, 0), + lig_entry_at(2, PUSH | ACT, 0), + lig_entry_at(0, PUSH | ACT, 2), + ]; + let states = [[0, 1, 0, 0], [0, 2, 0, 0], [0, 3, 0, 0]]; + // Action 0: comp[6] + comp[5] = 0. Action 2 (offset 0x64 = 100): + // comp[107] + comp[190] = 1. + let actions = [0, LS, 0x64, LS | 0x64]; + let bytes = ligature_morx(&states, &entries, &actions, &[(107, 1)], &[90, 91]); + let m = Morx::parse(&bytes).unwrap(); + assert_eq!(m.apply(&[5, 6, 7]), (vec![91], vec![0])); +} + +#[test] +fn ligature_store_without_last_and_running_index() { + // The first action stores (without Last) ligature 2 in place of + // the glyph it popped; the second (Last, no Store) adds to the same + // index and stores ligature 4 over glyph 0, deleting the first + // ligature. Store used to count only together with Last. + let entries = [ + lig_entry_at(0, 0, 0), + lig_entry_at(1, PUSH, 0), + lig_entry_at(0, PUSH | ACT, 0), + ]; + let states = [[0, 1, 0, 0], [0, 2, 0, 0]]; + let actions = [LIG_ACTION_STORE, LIG_ACTION_LAST]; + let bytes = ligature_morx(&states, &entries, &actions, &[(5, 2)], &[0, 0, 70, 0, 71]); + let m = Morx::parse(&bytes).unwrap(); + assert_eq!(m.apply(&[5, 5]).0, [71]); +} + +#[test] +fn ligature_action_at_end_of_text_does_nothing() { + // State 2's end-of-text entry performs the action. HarfBuzz acts + // only on a glyph, so [5, 6] stays as it is. + let entries = [ + lig_entry_at(0, 0, 0), + lig_entry_at(1, PUSH, 0), + lig_entry_at(2, PUSH, 0), + lig_entry_at(0, ACT, 0), + ]; + let states = [[0, 1, 0, 0], [0, 2, 0, 0], [3, 0, 0, 0]]; + let bytes = ligature_morx(&states, &entries, &[0, LS], &[], &[99]); + let m = Morx::parse(&bytes).unwrap(); + assert_eq!(m.apply(&[5, 6]).0, [5, 6]); +} + +#[test] +fn ligature_stack_underflow_clears_the_stack() { + // The action list wants three components but the stack holds two: + // nothing forms and the stack is cleared, so the next action, on + // two new components, underflows too instead of reaching back to + // the first two. + let entries = [ + lig_entry_at(0, 0, 0), + lig_entry_at(1, PUSH, 0), + lig_entry_at(0, PUSH | ACT, 0), + ]; + let states = [[0, 1, 0, 0], [0, 2, 0, 0]]; + let bytes = ligature_morx(&states, &entries, &[0, 0, LS], &[], &[99]); + let m = Morx::parse(&bytes).unwrap(); + assert_eq!(m.apply(&[5, 6, 7, 8]).0, [5, 6, 7, 8]); +} + +#[test] +fn deleted_components_stay_until_morx_is_done() { + // A ligature deletes glyph 1. The contextual subtable after it + // still sees that glyph, in the deleted-glyph class (2), and marks + // the ligature for substitution there; morx drops the deleted + // glyph only at the end, as HarfBuzz does. + let lig_entries = [ + lig_entry_at(0, 0, 0), + lig_entry_at(1, PUSH, 0), + lig_entry_at(0, PUSH | ACT, 0), + ]; + let lig_refs: Vec<&[u8]> = lig_entries.iter().map(Vec::as_slice).collect(); + let actions: Vec = [0, LS].iter().flat_map(|a: &u32| a.to_be_bytes()).collect(); + let comps = [0u8; 32]; + let lig = state_body( + 3, + &[[0, 1, 0, 0], [0, 2, 0, 0]], + &lig_refs, + &[&actions, &comps, &90u16.to_be_bytes()], + ); + let ctx_entries = [ + ctx_entry(0, FLAG_CTX_SET_MARK, 0xFFFF, 0xFFFF), + ctx_entry(0, 0, 0, 0xFFFF), + ]; + let ctx_refs: Vec<&[u8]> = ctx_entries.iter().map(Vec::as_slice).collect(); + let table = substitution_table(&[build_lookup_format6(&[(90, 77)])]); + // Out-of-bounds glyphs set the mark; a deleted glyph substitutes it. + let ctx = state_body(1, &[[0, 0, 1, 0]], &ctx_refs, &[&table]); + let bytes = wrap_in_chain(&[ + &subtable(TYPE_LIGATURE, &lig), + &subtable(TYPE_CONTEXTUAL, &ctx), + ]); + let m = Morx::parse(&bytes).unwrap(); + assert_eq!(m.apply(&[5, 6]), (vec![77], vec![0])); +} From 9f6142a7f9bbd240440e13d4b543dcfda9e03e88 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 09:35:20 -0800 Subject: [PATCH 08/13] Apply morx substitutions to glyph 1 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. --- src/tables/layout/state_table.rs | 30 +++++++++++++++++------------- src/tables/morx/contextual.rs | 26 +++++++++----------------- src/tables/morx/non_contextual.rs | 14 +++++--------- src/tables/morx/tests.rs | 13 +++++++++++++ 4 files changed, 44 insertions(+), 39 deletions(-) diff --git a/src/tables/layout/state_table.rs b/src/tables/layout/state_table.rs index 0beffb3f..7ee19dd4 100644 --- a/src/tables/layout/state_table.rs +++ b/src/tables/layout/state_table.rs @@ -251,6 +251,14 @@ fn read_u16_at(data: &[u8], off: usize, context: &'static str) -> Result { /// `n_glyphs` is required by format 0, which is just a flat u16 /// array sized by the font's `numGlyphs`. Other formats ignore it. pub(crate) fn lookup_class(data: &[u8], glyph_id: u16, n_glyphs: u16) -> Result { + Ok(lookup_value(data, glyph_id, n_glyphs)?.unwrap_or(CLASS_OUT_OF_BOUNDS)) +} + +/// The value [`lookup_class`] reads for `glyph_id`, or `None` when the +/// lookup does not cover the glyph. Substitution lookups need the +/// difference: a covered glyph may map to glyph 1, the same number as +/// the out-of-bounds class. +pub(crate) fn lookup_value(data: &[u8], glyph_id: u16, n_glyphs: u16) -> Result> { if data.len() < 2 { return Err(Error::Truncated { offset: 0, @@ -280,7 +288,7 @@ pub(crate) fn lookup_class(data: &[u8], glyph_id: u16, n_glyphs: u16) -> Result< // Used when the value stream is dense: kerx format 2's left- and // right-class tables are the canonical case, since they yield a // per-glyph byte offset that's almost always non-default. -fn lookup_format0(data: &[u8], glyph_id: u16, n_glyphs: u16) -> Result { +fn lookup_format0(data: &[u8], glyph_id: u16, n_glyphs: u16) -> Result> { // Two limits gate the read: the caller-supplied glyph count // (when known) and the slice's actual byte length. The smaller // of the two wins so a truncated table can never panic. @@ -292,7 +300,7 @@ fn lookup_format0(data: &[u8], glyph_id: u16, n_glyphs: u16) -> Result { }; let limit = declared.min(slice_cap); if usize::from(glyph_id) >= limit { - return Ok(CLASS_OUT_OF_BOUNDS); + return Ok(None); } let off = 2usize + glyph_id as usize * 2; // The bound check above already guarantees off + 2 <= data.len(), @@ -301,7 +309,7 @@ fn lookup_format0(data: &[u8], glyph_id: u16, n_glyphs: u16) -> Result { offset: off, context: "AAT lookup format 0 cell", })?; - Ok(u16::from_be_bytes([slice[0], slice[1]])) + Ok(Some(u16::from_be_bytes([slice[0], slice[1]]))) } // Format 2 layout: @@ -318,7 +326,7 @@ fn lookup_format0(data: &[u8], glyph_id: u16, n_glyphs: u16) -> Result { // (trailing sentinel segment (0xFFFF, 0xFFFF, 0) terminates the array) // // Segments are sorted by `lastGlyph`, so we can binary-search. -fn lookup_format2(data: &[u8], glyph_id: u16) -> Result { +fn lookup_format2(data: &[u8], glyph_id: u16) -> Result> { if data.len() < 12 { return Err(Error::Truncated { offset: 0, @@ -360,17 +368,13 @@ fn lookup_format2(data: &[u8], glyph_id: u16) -> Result { } } if lo == n_units { - return Ok(CLASS_OUT_OF_BOUNDS); + return Ok(None); } let off = body_off + lo * unit_size; let last = u16::from_be_bytes([data[off], data[off + 1]]); let first = u16::from_be_bytes([data[off + 2], data[off + 3]]); let value = u16::from_be_bytes([data[off + 4], data[off + 5]]); - if glyph_id >= first && glyph_id <= last { - Ok(value) - } else { - Ok(CLASS_OUT_OF_BOUNDS) - } + Ok((glyph_id >= first && glyph_id <= last).then_some(value)) } // Format 6 layout: @@ -386,7 +390,7 @@ fn lookup_format2(data: &[u8], glyph_id: u16) -> Result { // (trailing sentinel (0xFFFF, 0) optional) // // Records are sorted by `glyph`. -fn lookup_format6(data: &[u8], glyph_id: u16) -> Result { +fn lookup_format6(data: &[u8], glyph_id: u16) -> Result> { if data.len() < 12 { return Err(Error::Truncated { offset: 0, @@ -424,12 +428,12 @@ fn lookup_format6(data: &[u8], glyph_id: u16) -> Result { core::cmp::Ordering::Less => lo = mid + 1, core::cmp::Ordering::Equal => { let v = u16::from_be_bytes([data[off + 2], data[off + 3]]); - return Ok(v); + return Ok(Some(v)); } core::cmp::Ordering::Greater => hi = mid, } } - Ok(CLASS_OUT_OF_BOUNDS) + Ok(None) } #[cfg(test)] diff --git a/src/tables/morx/contextual.rs b/src/tables/morx/contextual.rs index 3572bd50..2fc9db7d 100644 --- a/src/tables/morx/contextual.rs +++ b/src/tables/morx/contextual.rs @@ -2,7 +2,7 @@ use super::{class_for, max_steps, FLAG_CTX_SET_MARK, FLAG_DONT_ADVANCE}; use crate::error::Result; -use crate::tables::layout::state_table::{lookup_class, StateTableHeader, CLASS_OUT_OF_BOUNDS}; +use crate::tables::layout::state_table::{self, StateTableHeader, CLASS_OUT_OF_BOUNDS}; // --- Type 1: Contextual glyph substitution --- @@ -88,25 +88,17 @@ pub(super) fn apply_contextual( fn sub_lookup(substitutions: &[u8], idx: u16, glyph: u16) -> Option { let at = usize::from(idx) * 4; let off = u32::from_be_bytes(*substitutions.get(at..)?.first_chunk::<4>()?) as usize; - let lookup = substitutions.get(off..)?; - // Reuse the class-lookup machinery: class value == replacement - // glyph id; out-of-bounds yields the reserved class, which we - // map back to None so the caller knows not to substitute. - let Ok(replacement) = lookup_value(lookup, glyph) else { - return None; - }; - if replacement == CLASS_OUT_OF_BOUNDS { - None - } else { - Some(replacement) - } + lookup_value(substitutions.get(off..)?, glyph) + .ok() + .flatten() } /// Resolves `glyph` through the AAT lookup table at the start of -/// `data`. Passes a glyph count of zero, so a format-0 lookup covers -/// as many glyphs as the slice holds, the same rule +/// `data`: its replacement, or `None` when the lookup does not cover +/// it. Passes a glyph count of zero, so a format-0 lookup covers as +/// many glyphs as the slice holds, the same rule /// [`StateTableHeader::class_of`] uses. Reads the lookup in place, /// without copying it. -pub(super) fn lookup_value(data: &[u8], glyph: u16) -> Result { - lookup_class(data, glyph, 0) +pub(super) fn lookup_value(data: &[u8], glyph: u16) -> Result> { + state_table::lookup_value(data, glyph, 0) } diff --git a/src/tables/morx/non_contextual.rs b/src/tables/morx/non_contextual.rs index 96818603..a18d11d2 100644 --- a/src/tables/morx/non_contextual.rs +++ b/src/tables/morx/non_contextual.rs @@ -1,21 +1,17 @@ //! `morx` type 4: non-contextual glyph substitution subtables. use super::contextual::lookup_value; -use crate::tables::layout::state_table::CLASS_OUT_OF_BOUNDS; // --- Type 4: Non-Contextual Substitution --- /// Walks every glyph in the run and replaces it with whatever the -/// subtable's AAT lookup yields. A lookup that returns -/// [`CLASS_OUT_OF_BOUNDS`] (the AAT "glyph not covered" sentinel) or -/// errors on a malformed slice falls through to "keep the original -/// glyph", so a partly-broken subtable can't blank out the run. +/// subtable's AAT lookup yields. A glyph the lookup does not cover, or +/// a lookup that errors on a malformed slice, keeps the original glyph, +/// so a partly-broken subtable can't blank out the run. pub(super) fn apply_non_contextual(lookup: &[u8], glyphs: &mut [u16]) { for slot in glyphs.iter_mut() { - if let Ok(replacement) = lookup_value(lookup, *slot) { - if replacement != CLASS_OUT_OF_BOUNDS { - *slot = replacement; - } + if let Ok(Some(replacement)) = lookup_value(lookup, *slot) { + *slot = replacement; } } } diff --git a/src/tables/morx/tests.rs b/src/tables/morx/tests.rs index c66b0098..c8c5fc16 100644 --- a/src/tables/morx/tests.rs +++ b/src/tables/morx/tests.rs @@ -913,3 +913,16 @@ fn deleted_components_stay_until_morx_is_done() { let m = Morx::parse(&bytes).unwrap(); assert_eq!(m.apply(&[5, 6]), (vec![77], vec![0])); } + +#[test] +fn substitutions_to_glyph_1_apply() { + // Glyph 1 is the same number as the out-of-bounds class, which the + // lookup reader used to return for "not covered", so a + // substitution to glyph 1 was dropped. + let bytes = build_non_contextual_morx(&[(5, 1)]); + assert_eq!(Morx::parse(&bytes).unwrap().apply(&[5, 6]).0, [1, 6]); + let lookups = [build_lookup_format6(&[(5, 1)])]; + let entries = [ctx_entry(0, 0, 0xFFFF, 0)]; + let bytes = contextual_morx(&[[0, 0, 0, 0]], &entries, &lookups); + assert_eq!(Morx::parse(&bytes).unwrap().apply(&[5, 6]).0, [1, 6]); +} From 10724bec1c1e5ebe349498f18d19940d29e6c2f7 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 09:39:02 -0800 Subject: [PATCH 09/13] Check VARC and morx against HarfBuzz on crafted fixtures 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). --- tests/aat_morx_parity.rs | 52 ++ tests/fixtures/README.md | 11 + tests/fixtures/aat_morx_contextual.ttf | Bin 0 -> 1060 bytes tests/fixtures/aat_morx_ligature.ttf | Bin 0 -> 4028 bytes tests/fixtures/aat_morx_parity.expected | 27 + tests/fixtures/varc_parity.expected | 114 +++++ tests/fixtures/varc_parity.ttf | Bin 0 -> 1776 bytes .../tools/build_varc_morx_parity_fixtures.py | 481 ++++++++++++++++++ tests/tools/varc_morx_parity_expected.py | 113 ++++ tests/varc_parity.rs | 183 +++++++ 10 files changed, 981 insertions(+) create mode 100644 tests/aat_morx_parity.rs create mode 100644 tests/fixtures/aat_morx_contextual.ttf create mode 100644 tests/fixtures/aat_morx_ligature.ttf create mode 100644 tests/fixtures/aat_morx_parity.expected create mode 100644 tests/fixtures/varc_parity.expected create mode 100644 tests/fixtures/varc_parity.ttf create mode 100644 tests/tools/build_varc_morx_parity_fixtures.py create mode 100644 tests/tools/varc_morx_parity_expected.py create mode 100644 tests/varc_parity.rs diff --git a/tests/aat_morx_parity.rs b/tests/aat_morx_parity.rs new file mode 100644 index 00000000..e5cd7e7a --- /dev/null +++ b/tests/aat_morx_parity.rs @@ -0,0 +1,52 @@ +//! AAT `morx` contextual and ligature subtables against HarfBuzz. +//! +//! `tests/fixtures/aat_morx_contextual.ttf` carries a contextual subtable +//! whose substitution table is the spec's unsized offset array, with +//! mark, current, end-of-text and unset-mark entries. +//! `tests/fixtures/aat_morx_ligature.ttf` carries a ligature subtable with +//! cascading ligatures (the ligature stays on the stack), a component set +//! twice through DontAdvance, Store without Last, an action at the end of +//! text, and a stack underflow. Neither font has GSUB, so both HarfBuzz +//! and sigilbuzz shape them through `morx`. +//! +//! `tests/fixtures/aat_morx_parity.expected` holds HarfBuzz 14.5.0's glyphs +//! and clusters for each string. `tests/tools/build_varc_morx_parity_fixtures.py` +//! builds the fonts and `tests/tools/varc_morx_parity_expected.py` writes +//! the expected file. + +use sigilbuzz::{shape, Blob, Buffer, Face, Font}; + +const CONTEXTUAL: &[u8] = include_bytes!("fixtures/aat_morx_contextual.ttf"); +const LIGATURE: &[u8] = include_bytes!("fixtures/aat_morx_ligature.ttf"); +const EXPECTED: &str = include_str!("fixtures/aat_morx_parity.expected"); + +#[test] +fn morx_shaping_matches_harfbuzz() { + let mut checked = 0; + for line in EXPECTED.lines().filter(|l| l.starts_with("shape ")) { + let mut fields = line.split(' ').skip(1); + let font_name = fields.next().unwrap(); + let text = fields.next().unwrap(); + let want: Vec<(u32, u32)> = fields + .map(|f| { + let (g, c) = f.split_once(':').unwrap(); + (g.parse().unwrap(), c.parse().unwrap()) + }) + .collect(); + let data = match font_name { + "aat_morx_contextual.ttf" => CONTEXTUAL, + "aat_morx_ligature.ttf" => LIGATURE, + other => panic!("unknown fixture {other}"), + }; + let blob = Blob::new(data); + let face = Face::parse(&blob, 0).unwrap(); + let font = Font::new(face, 1000.0); + let mut buf = Buffer::new(); + buf.push_str(text); + let run = shape(&font, &buf, &[]).unwrap(); + let got: Vec<(u32, u32)> = run.glyphs.iter().map(|g| (g.glyph_id, g.cluster)).collect(); + assert_eq!(got, want, "{font_name}: {text:?}"); + checked += 1; + } + assert_eq!(checked, 25); +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 12db0e36..bda8f1c1 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -171,3 +171,14 @@ Fonts used by the integration tests. --unicodes "U+ADC1,U+BE5B" --drop-layout `tests/cff2_closed_contours.rs` uses it. +- `varc_parity.ttf`, `aat_morx_contextual.ttf` and `aat_morx_ligature.ttf`: synthetic + fonts written byte by byte from the VARC and `morx` specs by + `tests/tools/build_varc_morx_parity_fixtures.py` (fontTools reads the VARC table back + to check the encoding). `varc_parity.ttf` has three axes and 14 VARC glyphs that use + every component record field over two `gvar`-varied glyphs; the two `morx` fonts have + no GSUB, one with a contextual subtable and one with a ligature subtable. + `tests/varc_parity.rs` and `tests/aat_morx_parity.rs` check them against + `varc_parity.expected` and `aat_morx_parity.expected`, HarfBuzz 14.5.0's outlines at + eight locations and its AAT shaping of 25 strings. Regenerate those files with `uv run + --no-project --with uharfbuzz==0.56.2 --with fonttools python + tests/tools/varc_morx_parity_expected.py`. diff --git a/tests/fixtures/aat_morx_contextual.ttf b/tests/fixtures/aat_morx_contextual.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b4c2a46f47393ee54a15b9926aa1a5759e37caa1 GIT binary patch literal 1060 zcmah{L1+^}6n(R^i7Cb=ha9{aN_!NGhay<9f))fzOU*&_5QlU(DeN{ONoDm^1i^!d zAc!J`9(wB8gNS(cBqAaR9t2T5dQh+H`?J|eQHpIY+@HjBCmtpf zzv)3e<}e?1TSgf3XF1eklk`x9$MvI@cl!x$__qLEme_`ggBKs~eC7TI*IT{rVE87S zk^JWzRw3GnsD1oPvoik+T7PE*A0FUE=Z|q@i%p6LRt zwx=GT0Dl}h&=Z}`&eBK>5*Pa0sWY%fY!I^RHgS)5MC=fI#53X*@tSx?d?LOP*^NZ6 zCTbkz^cr<8axSC9SMbq7h;>9r(v(rNpQYRB)AUJtpLy(pGsV0~$NuN|n6u|fe@fh( zy?}>9WK`r&Fj7Tjj0Bvkiv|-%!@*pMB zyYsk!i@1bkT*EYGl&7@vRRO25sKe$u5f%t~QB&SI>#}GiRCQEZd~ru{d!HJ){KGwB z2SvVU{vRlM9{-CXze(OVH_6tF=k_o6?E1Kl+n>_lZNbixZ*#|7R&7~ryD7Tsgy>ta b+GM|&NA03D=ZgCF^CFH|o>h}`N^*xkI_h<8 literal 0 HcmV?d00001 diff --git a/tests/fixtures/aat_morx_ligature.ttf b/tests/fixtures/aat_morx_ligature.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2b68740fadae98b42089f6ea70124c7a1086e0b6 GIT binary patch literal 4028 zcmeHKO=uHQ5dPlo=FhB6g&uNH4AR~Ni-%IMVg)SQ6Vn*EhJ4yQi$Loh=PKM zAc!JD4&tE)@!&zENW6Ix5fKCrUX+3t57q1X&2F+SWs6?4MRt~N-psuB=6!EwOE*ve z;%Gz1=4OaDFnbH(x{&WUJes8lI$<;VqFl{d;&J~dRjOjOY8ZsZ`K04l>#bM&Zg8z`0_KxNtCD~5 z@!A)zPjfz7t2UZ%3L~=rIn^A5JMk@<+>=R6{D7{nGJ+3x9?9U1j>DvKk|W%sr|fas z-`68O%N-INc3#Uynlw>Pv;xWxqNY{Y}GM?D(bv` z8hJF(WV`byVu_?J#=Tx{#YB2{0w-}A)0oA13}ZxTN>_#o;RvSmLS}&|ga}&YjMUlf zvT!BL$Gy~IOXX!&=HkR&;uKw0L|q`*9rH56tgPHtMDDF2#v2-ZLVUt}BG^T5fVwY| zF+|8d$?S@MRAV;3wUXaIL)bU3bix0$7&-L0`E%G9?Y|#dTUhHIr$*EiYTmmIN~kMZ zBCJ4L%!s3r63$WA?!Y-W*LB`#b+8NmbwRClq0KYxN*6kp>sL9hPQsXO!+457f(rb- z3doxt$WE$&wv)&NT$~EXS4JS)Ux7dQ4&8p10R#C``yV9u@A&V#7~?l`oWII;JHMmw E3y^YoZU6uP literal 0 HcmV?d00001 diff --git a/tests/fixtures/aat_morx_parity.expected b/tests/fixtures/aat_morx_parity.expected new file mode 100644 index 00000000..1b50a6cd --- /dev/null +++ b/tests/fixtures/aat_morx_parity.expected @@ -0,0 +1,27 @@ +# HarfBuzz 14.5.0 (uharfbuzz 0.56.2) AAT shaping of the morx fixtures. +# shape :... +shape aat_morx_contextual.ttf ab 7:0 8:1 +shape aat_morx_contextual.ttf ba 2:0 1:1 +shape aat_morx_contextual.ttf aab 1:0 7:1 8:2 +shape aat_morx_contextual.ttf abab 7:0 8:1 7:2 8:3 +shape aat_morx_contextual.ttf x 13:0 +shape aat_morx_contextual.ttf xa 6:0 1:1 +shape aat_morx_contextual.ttf cx 7:0 13:1 +shape aat_morx_contextual.ttf ca 7:0 1:1 +shape aat_morx_contextual.ttf acb 8:0 14:1 2:2 +shape aat_morx_contextual.ttf xx 6:0 13:1 +shape aat_morx_contextual.ttf bx 2:0 13:1 +shape aat_morx_ligature.ttf fi 9:0 +shape aat_morx_ligature.ttf ff 10:0 +shape aat_morx_ligature.ttf ffi 11:0 +shape aat_morx_ligature.ttf fff 10:0 3:2 +shape aat_morx_ligature.ttf fffi 10:0 9:2 +shape aat_morx_ligature.ttf ffffi 10:0 11:2 +shape aat_morx_ligature.ttf lx 12:0 +shape aat_morx_ligature.ttf lxlx 12:0 12:2 +shape aat_morx_ligature.ttf xlx 6:0 12:1 +shape aat_morx_ligature.ttf ab 1:0 2:1 +shape aat_morx_ligature.ttf abc 1:0 2:1 14:2 +shape aat_morx_ligature.ttf cc 8:0 +shape aat_morx_ligature.ttf ccc 8:0 14:2 +shape aat_morx_ligature.ttf fiab 9:0 1:2 2:3 diff --git a/tests/fixtures/varc_parity.expected b/tests/fixtures/varc_parity.expected new file mode 100644 index 00000000..c04c3bc9 --- /dev/null +++ b/tests/fixtures/varc_parity.expected @@ -0,0 +1,114 @@ +# HarfBuzz 14.5.0 (uharfbuzz 0.56.2) outlines of varc_parity.ttf. +# outline : M/L x y, Q cx cy x y, C ..., Z. +outline default 3 v_affine M 10 20 L 139.8846 95.0332 L 64.8514 224.9178 L -65.0332 149.8846 Z +outline default 4 v_skew M 22.984 -0.4957 L 103.0621 -19.5238 L 77.016 100.4957 L -3.0621 119.5238 Z +outline default 5 v_axes M 12.5 0 L 137.5 0 L 137.5 100 L 12.5 100 Z +outline default 6 v_tvar M 100 0 L 200 0 L 200 100 L 100 100 Z +outline default 7 v_axisvar_noaxes M 200 0 L 300 0 L 300 100 L 200 100 Z +outline default 8 v_nested M 312.2253 0 L 434.4788 0 L 437.8167 115.3374 L 312.2253 111.9995 Z M 496.9069 -9.5096 L 690.1923 -61.8618 L 668.8695 260.5438 Z +outline default 9 v_reset1 M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline default 10 v_cond M 300 0 L 400 0 L 400 100 L 300 100 Z M 750 0 L 950 0 L 850 300 Z +outline default 11 v_reserved M 10 0 L 110 0 L 110 100 L 10 100 Z M 500 0 L 700 0 L 600 300 Z +outline default 12 v_inner M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline default 13 v_outer M 0 10 L 110.0006 10 L 110.0006 110 L 0 110 Z +outline default 14 v_inner_keep M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline default 15 v_outer_keep M 0 10 L 110.0006 10 L 113.5009 141.5008 L 0 138.0005 Z +outline default 16 v_bigaxis M 1 0 L 101 0 L 101 100 L 1 100 Z +outline AAAA=50,BBBB=-30,CCCC=70 3 v_affine M 10 20 L 172.3558 113.7915 L 97.3226 243.6761 L -65.0332 149.8846 Z +outline AAAA=50,BBBB=-30,CCCC=70 4 v_skew M 22.984 -0.4957 L 123.0817 -24.2808 L 97.0355 95.7387 L -3.0621 119.5238 Z +outline AAAA=50,BBBB=-30,CCCC=70 5 v_axes M 10.6689 0 L 137.1948 0 L 137.1948 100 L 10.6689 100 Z +outline AAAA=50,BBBB=-30,CCCC=70 6 v_tvar M 125 -10 L 263.8903 12.0199 L 246.2744 123.1321 L 107.3841 101.1122 Z +outline AAAA=50,BBBB=-30,CCCC=70 7 v_axisvar_noaxes M 200 0 L 325 0 L 325 100 L 200 100 Z +outline AAAA=50,BBBB=-30,CCCC=70 8 v_nested M 310.3943 0 L 434.1736 0 L 437.7403 115.5663 L 310.3943 111.9995 Z M 496.9069 -9.5096 L 690.1923 -61.8618 L 668.8695 260.5438 Z +outline AAAA=50,BBBB=-30,CCCC=70 9 v_reset1 M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline AAAA=50,BBBB=-30,CCCC=70 10 v_cond M 0 0 L 125 0 L 125 100 L 0 100 Z M 750 -14.0002 L 950 0 L 821.9995 300 Z +outline AAAA=50,BBBB=-30,CCCC=70 11 v_reserved M 10 0 L 135 0 L 135 100 L 10 100 Z M 500 -14.0002 L 700 0 L 571.9995 300 Z +outline AAAA=50,BBBB=-30,CCCC=70 12 v_inner M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline AAAA=50,BBBB=-30,CCCC=70 13 v_outer M 0 10 L 110.0006 10 L 110.0006 110 L 0 110 Z +outline AAAA=50,BBBB=-30,CCCC=70 14 v_inner_keep M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline AAAA=50,BBBB=-30,CCCC=70 15 v_outer_keep M 0 10 L 110.0006 10 L 113.5009 141.5008 L 0 138.0005 Z +outline AAAA=50,BBBB=-30,CCCC=70 16 v_bigaxis M 1 0 L 126 0 L 126 100 L 1 100 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 3 v_affine M 14.8707 22.8137 L 166.3816 110.3403 L 74.3756 286.2565 L -80.1812 187.3512 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 4 v_skew M 25.9869 -1.2093 L 119.3984 -23.4056 L 89.4035 134.2426 L -7.0082 150.8311 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 5 v_axes M 10.6689 0 L 130.5786 0 L 137.2184 133.3195 L 10.6689 126.6797 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 6 v_tvar M 100.3696 33.7513 L 216.3668 45.9431 L 208.0682 178.0155 L 87.1296 159.7216 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 7 v_axisvar_noaxes M 203.75 0 L 320.4004 0 L 325.9532 132.2325 L 203.75 126.6797 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 8 v_nested M 311.0059 0 L 434.2755 0 L 437.7658 115.4898 L 311.0059 111.9995 Z M 496.9322 -9.4318 L 728.2494 -74.2401 L 685.5297 311.2359 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 9 v_reset1 M 3.75 0 L 113.7506 0 L 117.0858 130.0148 L 3.75 126.6797 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 10 v_cond M 3.75 0 L 120.4004 0 L 125.9532 132.2325 L 3.75 126.6797 Z M 450 0 L 690.0195 0 L 550 353.3594 Z M 750 0 L 990.0195 0 L 850 353.3594 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 11 v_reserved M 13.75 0 L 130.4004 0 L 135.9532 132.2325 L 13.75 126.6797 Z M 500 0 L 740.0195 0 L 600 353.3594 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 12 v_inner M 3.75 0 L 113.7506 0 L 117.0858 130.0148 L 3.75 126.6797 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 13 v_outer M 3.75 10 L 113.7506 10 L 117.0858 140.0148 L 3.75 136.6797 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 14 v_inner_keep M 3.75 0 L 113.7506 0 L 117.0858 130.0148 L 3.75 126.6797 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 15 v_outer_keep M 3.75 10 L 113.7506 10 L 117.2509 141.5008 L 3.75 138.0005 Z +outline AAAA=33.3,BBBB=66.7,CCCC=-12.5 16 v_bigaxis M 4.75 0 L 121.4004 0 L 126.9532 132.2325 L 4.75 126.6797 Z +outline AAAA=-80,BBBB=90,CCCC=55 3 v_affine M 10 20 L 139.8846 95.0332 L 37.8387 271.6776 L -92.0459 196.6444 Z +outline AAAA=-80,BBBB=90,CCCC=55 4 v_skew M 22.984 -0.4957 L 103.0621 -19.5238 L 67.6391 143.7039 L -12.439 162.732 Z +outline AAAA=-80,BBBB=90,CCCC=55 5 v_axes M 11.676 0 L 128.4363 0 L 135.9786 143.5433 L 11.676 136.001 Z +outline AAAA=-80,BBBB=90,CCCC=55 6 v_tvar M 72.9993 54.0015 L 161.749 54.0015 L 161.749 174.7019 L 72.9993 174.7019 Z +outline AAAA=-80,BBBB=90,CCCC=55 7 v_axisvar_noaxes M 200 0 L 300 0 L 300 136.001 L 200 136.001 Z +outline AAAA=-80,BBBB=90,CCCC=55 8 v_nested M 312.2253 0 L 434.4788 0 L 437.8167 115.3374 L 312.2253 111.9995 Z M 496.9069 -9.5096 L 741.5457 -78.5649 L 691.1404 329.0149 Z +outline AAAA=-80,BBBB=90,CCCC=55 9 v_reset1 M 0 0 L 110.0006 0 L 114.501 140.5014 L 0 136.001 Z +outline AAAA=-80,BBBB=90,CCCC=55 10 v_cond M 450 -10.9998 L 704.0015 0 L 528.0005 372.002 Z M 750 -10.9998 L 1004.0015 0 L 828.0005 372.002 Z +outline AAAA=-80,BBBB=90,CCCC=55 11 v_reserved M 10 0 L 110 0 L 110 136.001 L 10 136.001 Z M 500 -10.9998 L 754.0015 0 L 578.0005 372.002 Z +outline AAAA=-80,BBBB=90,CCCC=55 12 v_inner M 0 0 L 110.0006 0 L 114.501 140.5014 L 0 136.001 Z +outline AAAA=-80,BBBB=90,CCCC=55 13 v_outer M 0 10 L 110.0006 10 L 114.501 150.5014 L 0 146.001 Z +outline AAAA=-80,BBBB=90,CCCC=55 14 v_inner_keep M 0 0 L 110.0006 0 L 114.501 140.5014 L 0 136.001 Z +outline AAAA=-80,BBBB=90,CCCC=55 15 v_outer_keep M 0 10 L 110.0006 10 L 113.5009 141.5008 L 0 138.0005 Z +outline AAAA=-80,BBBB=90,CCCC=55 16 v_bigaxis M 1 0 L 101 0 L 101 136.001 L 1 136.001 Z +outline AAAA=70,BBBB=-100,CCCC=20 3 v_affine M 10 20 L 185.345 121.2953 L 110.3118 251.1799 L -65.0332 149.8846 Z +outline AAAA=70,BBBB=-100,CCCC=20 4 v_skew M 22.984 -0.4957 L 131.09 -26.1838 L 105.0438 93.8358 L -3.0621 119.5238 Z +outline AAAA=70,BBBB=-100,CCCC=20 5 v_axes M 9.9365 0 L 137.0728 0 L 137.0728 100 L 9.9365 100 Z +outline AAAA=70,BBBB=-100,CCCC=20 6 v_tvar M 135.0006 -14.0002 L 289.799 20.6368 L 264.142 135.3017 L 109.3436 100.6647 Z +outline AAAA=70,BBBB=-100,CCCC=20 7 v_axisvar_noaxes M 200 0 L 335.0006 0 L 335.0006 100 L 200 100 Z +outline AAAA=70,BBBB=-100,CCCC=20 8 v_nested M 309.6619 0 L 434.0515 0 L 437.7098 115.6578 L 309.6619 111.9995 Z M 496.9069 -9.5096 L 690.1923 -61.8618 L 668.8695 260.5438 Z +outline AAAA=70,BBBB=-100,CCCC=20 9 v_reset1 M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline AAAA=70,BBBB=-100,CCCC=20 10 v_cond M 0 0 L 135.0006 0 L 135.0006 100 L 0 100 Z M 150 -4.0002 L 350 0 L 241.9995 300 Z M 300 0 L 435.0006 0 L 435.0006 100 L 300 100 Z M 750 -4.0002 L 950 0 L 841.9995 300 Z +outline AAAA=70,BBBB=-100,CCCC=20 11 v_reserved M 10 0 L 145.0006 0 L 145.0006 100 L 10 100 Z M 500 -4.0002 L 700 0 L 591.9995 300 Z +outline AAAA=70,BBBB=-100,CCCC=20 12 v_inner M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline AAAA=70,BBBB=-100,CCCC=20 13 v_outer M 0 10 L 110.0006 10 L 110.0006 110 L 0 110 Z +outline AAAA=70,BBBB=-100,CCCC=20 14 v_inner_keep M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline AAAA=70,BBBB=-100,CCCC=20 15 v_outer_keep M 0 10 L 110.0006 10 L 113.5009 141.5008 L 0 138.0005 Z +outline AAAA=70,BBBB=-100,CCCC=20 16 v_bigaxis M 1 0 L 136.0006 0 L 136.0006 100 L 1 100 Z +outline AAAA=100,BBBB=100,CCCC=-100 3 v_affine M 48.9654 42.51 L 243.7923 155.0598 L 152.4587 388.1277 L -56.0811 224.3484 Z +outline AAAA=100,BBBB=100,CCCC=-100 4 v_skew M 47.0074 -6.2041 L 167.1246 -34.7463 L 144.168 158.5289 L 10.5429 161.8232 Z +outline AAAA=100,BBBB=100,CCCC=-100 5 v_axes M 7.9224 0 L 126.8188 0 L 136.2671 149.4482 L 7.9224 140 Z +outline AAAA=100,BBBB=100,CCCC=-100 6 v_tvar M 152.095 50.4392 L 312.5697 102.635 L 281.9001 287.8566 L 103.3788 200.2156 Z +outline AAAA=100,BBBB=100,CCCC=-100 7 v_axisvar_noaxes M 230 0 L 380 0 L 405 165 L 230 140 Z +outline AAAA=100,BBBB=100,CCCC=-100 8 v_nested M 308.5632 0 L 433.8684 0 L 437.664 115.7951 L 308.5632 111.9995 Z M 497.0202 -9.1614 L 747.25 -80.4203 L 694.3108 336.3942 Z +outline AAAA=100,BBBB=100,CCCC=-100 9 v_reset1 M 30 0 L 140.0006 0 L 145.0009 145.0003 L 30 140 Z +outline AAAA=100,BBBB=100,CCCC=-100 10 v_cond M 30 0 L 180 0 L 205 165 L 30 140 Z M 150 0 L 410 0 L 250 380 Z M 450 0 L 710 0 L 550 380 Z M 750 0 L 1010 0 L 850 380 Z +outline AAAA=100,BBBB=100,CCCC=-100 11 v_reserved M 40 0 L 190 0 L 215 165 L 40 140 Z M 500 0 L 760 0 L 600 380 Z +outline AAAA=100,BBBB=100,CCCC=-100 12 v_inner M 30 0 L 140.0006 0 L 145.0009 145.0003 L 30 140 Z +outline AAAA=100,BBBB=100,CCCC=-100 13 v_outer M 30 10 L 140.0006 10 L 145.0009 155.0003 L 30 150 Z +outline AAAA=100,BBBB=100,CCCC=-100 14 v_inner_keep M 30 0 L 140.0006 0 L 145.0009 145.0003 L 30 140 Z +outline AAAA=100,BBBB=100,CCCC=-100 15 v_outer_keep M 30 10 L 140.0006 10 L 143.5009 141.5008 L 30 138.0005 Z +outline AAAA=100,BBBB=100,CCCC=-100 16 v_bigaxis M 31 0 L 181 0 L 206 165 L 31 140 Z +outline AAAA=25,BBBB=0,CCCC=0 3 v_affine M 10 20 L 156.1202 104.4124 L 81.087 234.297 L -65.0332 149.8846 Z +outline AAAA=25,BBBB=0,CCCC=0 4 v_skew M 22.984 -0.4957 L 113.0719 -21.9023 L 87.0258 98.1172 L -3.0621 119.5238 Z +outline AAAA=25,BBBB=0,CCCC=0 5 v_axes M 11.5845 0 L 137.3474 0 L 137.3474 100 L 11.5845 100 Z +outline AAAA=25,BBBB=0,CCCC=0 6 v_tvar M 112.5 -5 L 231.662 4.3875 L 223.3176 110.3093 L 104.1556 100.9218 Z +outline AAAA=25,BBBB=0,CCCC=0 7 v_axisvar_noaxes M 200 0 L 312.5 0 L 312.5 100 L 200 100 Z +outline AAAA=25,BBBB=0,CCCC=0 8 v_nested M 311.3098 0 L 434.3262 0 L 437.7785 115.4518 L 311.3098 111.9995 Z M 496.9069 -9.5096 L 690.1923 -61.8618 L 668.8695 260.5438 Z +outline AAAA=25,BBBB=0,CCCC=0 9 v_reset1 M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline AAAA=25,BBBB=0,CCCC=0 10 v_cond M 0 0 L 112.5 0 L 112.5 100 L 0 100 Z M 300 0 L 412.5 0 L 412.5 100 L 300 100 Z M 750 0 L 950 0 L 850 300 Z +outline AAAA=25,BBBB=0,CCCC=0 11 v_reserved M 10 0 L 122.5 0 L 122.5 100 L 10 100 Z M 500 0 L 700 0 L 600 300 Z +outline AAAA=25,BBBB=0,CCCC=0 12 v_inner M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline AAAA=25,BBBB=0,CCCC=0 13 v_outer M 0 10 L 110.0006 10 L 110.0006 110 L 0 110 Z +outline AAAA=25,BBBB=0,CCCC=0 14 v_inner_keep M 0 0 L 110.0006 0 L 110.0006 100 L 0 100 Z +outline AAAA=25,BBBB=0,CCCC=0 15 v_outer_keep M 0 10 L 110.0006 10 L 113.5009 141.5008 L 0 138.0005 Z +outline AAAA=25,BBBB=0,CCCC=0 16 v_bigaxis M 1 0 L 113.5 0 L 113.5 100 L 1 100 Z +outline AAAA=-50,BBBB=10,CCCC=60 3 v_affine M 10 20 L 139.8846 95.0332 L 61.849 230.1151 L -68.0356 155.0819 Z +outline AAAA=-50,BBBB=10,CCCC=60 4 v_skew M 22.984 -0.4957 L 103.0621 -19.5238 L 75.9738 105.2982 L -4.1044 124.3264 Z +outline AAAA=-50,BBBB=10,CCCC=60 5 v_axes M 12.4084 0 L 136.4929 0 L 137.6976 105.2061 L 12.4084 104.0015 Z +outline AAAA=-50,BBBB=10,CCCC=60 6 v_tvar M 96.9989 6.0022 L 195.7484 6.0022 L 195.7484 108.7032 L 96.9989 108.7032 Z +outline AAAA=-50,BBBB=10,CCCC=60 7 v_axisvar_noaxes M 200 0 L 300 0 L 300 104.0015 L 200 104.0015 Z +outline AAAA=-50,BBBB=10,CCCC=60 8 v_nested M 312.2253 0 L 434.4788 0 L 437.8167 115.3374 L 312.2253 111.9995 Z M 496.9069 -9.5096 L 695.9002 -63.7183 L 671.3449 268.1543 Z +outline AAAA=-50,BBBB=10,CCCC=60 9 v_reset1 M 0 0 L 110.0006 0 L 110.5008 104.5017 L 0 104.0015 Z +outline AAAA=-50,BBBB=10,CCCC=60 10 v_cond M 450 -12.0007 L 656.0022 0 L 525.9985 308.0029 Z M 750 -12.0007 L 956.0022 0 L 825.9985 308.0029 Z +outline AAAA=-50,BBBB=10,CCCC=60 11 v_reserved M 10 0 L 110 0 L 110 104.0015 L 10 104.0015 Z M 500 -12.0007 L 706.0022 0 L 575.9985 308.0029 Z +outline AAAA=-50,BBBB=10,CCCC=60 12 v_inner M 0 0 L 110.0006 0 L 110.5008 104.5017 L 0 104.0015 Z +outline AAAA=-50,BBBB=10,CCCC=60 13 v_outer M 0 10 L 110.0006 10 L 110.5008 114.5017 L 0 114.0015 Z +outline AAAA=-50,BBBB=10,CCCC=60 14 v_inner_keep M 0 0 L 110.0006 0 L 110.5008 104.5017 L 0 104.0015 Z +outline AAAA=-50,BBBB=10,CCCC=60 15 v_outer_keep M 0 10 L 110.0006 10 L 113.5009 141.5008 L 0 138.0005 Z +outline AAAA=-50,BBBB=10,CCCC=60 16 v_bigaxis M 1 0 L 101 0 L 101 104.0015 L 1 104.0015 Z diff --git a/tests/fixtures/varc_parity.ttf b/tests/fixtures/varc_parity.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9d848328938e6ba42121ec44e01e8f16a19fba87 GIT binary patch literal 1776 zcmah~O=w(I6#mY8Zzhw;B$@os_(vUMkzxmGCQ&2CIxlgdP%xw=WFynpnU^1$%!HZA z7&r2UR3k2=Du@*`Pku%JL>KLGDGQ!X?JSk$h+Tt2sjR9f9IFHWhC$k`D@gV%+|c} zrJpbV21E&QbZ+sr*~4#M`U9{=h|_b_M|X}rOZ`#ebMwAe=#0FbAg}&U%~N3iY7a7A zKkY-IxQAvVXRv7^g-WxC zk@Ua`HMc$Q*x1$#JtMUEf6_c#^gM~c#bisgmq|4<+bg~c*1T8`^z*B`^fR9nS0~JT zve6)N8;!=sf6mx}efJ#Adx~LXwmex z&_-la{jnqsZRNG zD~n#)C5@9NNSKC)GV(YM9~D$F#dQuVSTu5%fpmVvQn3(k;&RO|wAFG8#iC#4T3V_4{__A95Hy56)!i3mS28=UoE6x=j3E#UZsgiO+ZxF@ zh(ulM!mS_FkyC#^9^yQ}Ij-RnTUkC5bGt3rbZG~AsE`d>@Id@yq!e?3b4QB}Y@2kA z!sO&cV;#Du=G*rceY-?D_dovhbJMS^z4y_%&%ZifU#2pSe(Q@UHcE82N1QD=+ZaX$ znSIo`Df~{yNPQI|d^9A788hN$FCvod8z0Ao~5uGR6=8g||Q{skb5 B{s#a6 literal 0 HcmV?d00001 diff --git a/tests/tools/build_varc_morx_parity_fixtures.py b/tests/tools/build_varc_morx_parity_fixtures.py new file mode 100644 index 00000000..9126291e --- /dev/null +++ b/tests/tools/build_varc_morx_parity_fixtures.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Write the VARC and morx fonts of tests/varc_parity.rs and +tests/aat_morx_parity.rs. + +Both tables are written byte by byte from their specs, so every flag +and edge case the parity tests need is under control: + +- `varc_parity.ttf`: a TrueType font with three axes (AAAA, BBBB, CCCC, + -100 to 100, default 0) whose glyphs `box` and `tri` vary through + gvar, and 14 VARC glyphs over them: transforms (ScaleY defaulting to + ScaleX, skew, center), axis values with deltas over several regions, + transform deltas, an axis-values index without axes, a nested + composite, conditions of every format, reserved flag bits, + RESET_UNSPECIFIED_AXES at the top and one level down, and an axis + index past HarfBuzz's 4096-axis limit. fontTools reads the table + back to check the encoding. +- `aat_morx_contextual.ttf` and `aat_morx_ligature.ttf`: AAT fonts + without GSUB. The first has a contextual subtable with mark, current, + end-of-text and unset-mark entries over an unsized substitution + table; the second a ligature subtable with cascading ligatures, a + component set twice (DontAdvance), Store without Last, an action at + end of text, and a stack underflow. + +tests/tools/varc_morx_parity_expected.py records HarfBuzz's output for +them. + +Run: + uv run --no-project --with fonttools python tests/tools/build_varc_morx_parity_fixtures.py +""" + +from __future__ import annotations + +import struct +from pathlib import Path + +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables.DefaultTable import DefaultTable +from fontTools.ttLib.tables.TupleVariation import TupleVariation + +FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" + +AXES = ["AAAA", "BBBB", "CCCC"] + +# --- VARC encoding -------------------------------------------------------- + +def u32var(v): + if v < 0x80: + return struct.pack(">B", v) + if v < 0x4000: + return struct.pack(">H", v | 0x8000) + if v < 0x200000: + return struct.pack(">L", v | 0xC00000)[1:] + if v < 0x10000000: + return struct.pack(">L", v | 0xE0000000) + return b"\xF0" + struct.pack(">L", v) + +def tuple_values(values): + out = bytearray() + i = 0 + while i < len(values): + v = values[i] + kind = 0 if -128 <= v <= 127 else (1 if -32768 <= v <= 32767 else 2) + run = [] + while i < len(values) and len(run) < 64: + w = values[i] + k = 0 if -128 <= w <= 127 else (1 if -32768 <= w <= 32767 else 2) + if k != kind: + break + run.append(w); i += 1 + ctrl = {0: 0x00, 1: 0x40, 2: 0xC0}[kind] | (len(run) - 1) + out.append(ctrl) + for w in run: + out += struct.pack({0: ">b", 1: ">h", 2: ">i"}[kind], w) + return bytes(out) + +def cff2_index(items): + out = struct.pack(">L", len(items)) + if not items: + return out + total = sum(len(x) for x in items) + 1 + size = 1 if total < 0x100 else 2 if total < 0x10000 else 3 if total < 0x1000000 else 4 + out += bytes([size]) + off = 1 + offs = [off] + for x in items: + off += len(x); offs.append(off) + for o in offs: + out += o.to_bytes(size, "big") + for x in items: + out += x + return out + +def coverage(gids): + return struct.pack(">HH", 1, len(gids)) + b"".join(struct.pack(">H", g) for g in gids) + +def f2(v): + return struct.pack(">h", round(v * 16384)) + +def multi_var_store(regions, subtables): + """regions: list of [(axis, start, peak, end)]. subtables: list of + (region_indices, [delta sets as region-major value lists]).""" + rl = struct.pack(">H", len(regions)) + bodies = b"" + for r in regions: + rl_off = 2 + 4 * len(regions) + len(bodies) + rl += struct.pack(">L", rl_off) + bodies += struct.pack(">H", len(r)) + b"".join( + struct.pack(">H", a) + f2(s) + f2(p) + f2(e) for a, s, p, e in r) + rl += bodies + head_len = 2 + 4 + 2 + 4 * len(subtables) + subs = [] + for indices, sets in subtables: + sub = struct.pack(">BH", 1, len(indices)) + b"".join(struct.pack(">H", i) for i in indices) + sub += cff2_index([tuple_values(s) for s in sets]) + subs.append(sub) + out = struct.pack(">HLH", 1, head_len, len(subtables)) + off = head_len + len(rl) + for s in subs: + out += struct.pack(">L", off); off += len(s) + return out + rl + b"".join(subs) + +def off24(v): + return struct.pack(">L", v)[1:] + +def cond_axis(axis, lo, hi): + return struct.pack(">HH", 1, axis) + f2(lo) + f2(hi) + +def cond_value(default, var_idx): + return struct.pack(">HhL", 2, default, var_idx) + +def cond_list_op(fmt, children): + """And (3) / Or (4) with inline children placed after the offsets.""" + head = struct.pack(">HB", fmt, len(children)) + off = len(head) + 3 * len(children) + offs = b""; body = b"" + for c in children: + offs += off24(off + len(body)); body += c + return head + offs + body + +def cond_not(child): + return struct.pack(">H", 5) + off24(5) + child + +def condition_list(conds): + out = struct.pack(">L", len(conds)) + off = 4 + 4 * len(conds) + offs = b""; body = b"" + for c in conds: + offs += struct.pack(">L", off + len(body)); body += c + return out + offs + body + +FLAG = dict(RESET=1 << 0, HAVE_AXES=1 << 1, AXIS_VAR=1 << 2, TRANSFORM_VAR=1 << 3, + TX=1 << 4, TY=1 << 5, ROT=1 << 6, COND=1 << 7, SX=1 << 8, SY=1 << 9, + CX=1 << 10, CY=1 << 11, GID24=1 << 12, SKX=1 << 13, SKY=1 << 14) +FIELD_ORDER = ["TX", "TY", "ROT", "SX", "SY", "SKX", "SKY", "CX", "CY"] + +def component(gid, *, reset=False, cond=None, axes=None, axis_var=None, + transform_var=None, fields=None, reserved=()): + """axes: (axis_indices_index, [F2DOT14 ints]). fields: raw int16 per + FIELD_ORDER name. reserved: [(bit, value)] extra uint32vars.""" + flags = 0 + body = b"" + if reset: + flags |= FLAG["RESET"] + body += struct.pack(">H", gid) + if cond is not None: + flags |= FLAG["COND"]; body += u32var(cond) + if axes is not None: + flags |= FLAG["HAVE_AXES"]; body += u32var(axes[0]) + tuple_values(axes[1]) + if axis_var is not None: + flags |= FLAG["AXIS_VAR"]; body += u32var(axis_var) + if transform_var is not None: + flags |= FLAG["TRANSFORM_VAR"]; body += u32var(transform_var) + for name in FIELD_ORDER: + if fields and name in fields: + flags |= FLAG[name]; body += struct.pack(">h", fields[name]) + for bit, value in reserved: + flags |= 1 << bit; body += u32var(value) + return u32var(flags) + body + +def varc_table(cov_gids, records, store=b"", conds=b"", axis_lists=()): + parts = [coverage(cov_gids), store, conds, + cff2_index([tuple_values(l) for l in axis_lists]) if axis_lists else b"", + cff2_index(records)] + out = b"" + off = 24 + offsets = [] + for p in parts: + offsets.append(off if p else 0) + out += p; off += len(p) + offsets[4] = offsets[4] or 24 + sum(len(p) for p in parts[:4]) + return struct.pack(">HHLLLLL", 1, 0, *offsets) + out + +# --- font ----------------------------------------------------------------- + +def fixed_timestamps(fb): + """Pins head.created and head.modified, so a rebuild writes the same + bytes.""" + fb.font.recalcTimestamp = False + fb.font["head"].created = fb.font["head"].modified = 3_600_000_000 + + +def rect(x0, y0, x1, y1): + pen = TTGlyphPen(None) + pen.moveTo((x0, y0)); pen.lineTo((x1, y0)); pen.lineTo((x1, y1)); pen.lineTo((x0, y1)); pen.closePath() + return pen.glyph() + +def tri(): + pen = TTGlyphPen(None) + pen.moveTo((0, 0)); pen.lineTo((200, 0)); pen.lineTo((100, 300)); pen.closePath() + return pen.glyph() + +def empty(): + return TTGlyphPen(None).glyph() + +def build_varc_font(path, varc_glyphs, make_varc): + order = [".notdef", "box", "tri"] + varc_glyphs + fb = FontBuilder(1000, isTTF=True) + fb.setupGlyphOrder(order) + fb.setupCharacterMap({0x41 + i: n for i, n in enumerate(order[1:])}) + glyphs = {".notdef": empty(), "box": rect(0, 0, 100, 100), "tri": tri()} + for n in varc_glyphs: + glyphs[n] = empty() + fb.setupGlyf(glyphs) + glyf = fb.font["glyf"] + fb.setupHorizontalMetrics({n: (600, getattr(glyf[n], "xMin", 0)) for n in order}) + fb.setupHorizontalHeader(ascent=800, descent=-200) + fb.setupNameTable({"familyName": "VarcTest", "styleName": "Regular"}) + fb.setupOS2() + fb.setupPost() + fb.setupFvar(axes=[(t, -100, 0, 100, t) for t in AXES], instances=[]) + z = (0, 0) + fb.setupGvar({ + # box points: (0,0) (100,0) (100,100) (0,100) + 4 phantoms + "box": [ + TupleVariation({"AAAA": (0, 1, 1)}, [z, (50, 0), (50, 0), z, z, z, z, z]), + TupleVariation({"BBBB": (0, 1, 1)}, [z, z, (0, 40), (0, 40), z, z, z, z]), + TupleVariation({"CCCC": (-1, -1, 0)}, [(30, 0), (30, 0), (30, 0), (30, 0), z, z, z, z]), + TupleVariation({"AAAA": (0, 1, 1), "BBBB": (0, 1, 1)}, [z, z, (25, 25), z, z, z, z, z]), + ], + "tri": [ + TupleVariation({"BBBB": (0, 1, 1)}, [z, (60, 0), (0, 80), z, z, z, z]), + TupleVariation({"CCCC": (0, 1, 1)}, [(0, -20), z, (-40, 0), z, z, z, z]), + ], + }) + gid = {n: i for i, n in enumerate(order)} + t = DefaultTable("VARC") + t.data = make_varc(gid) + fb.font["VARC"] = t + fixed_timestamps(fb) + fb.save(path) + # Read back with fontTools' VARC decoder. + f = TTFont(path) + f["VARC"].table # decompiles + return gid + +# --- VARC glyphs --------------------------------------------------------- + +VARC_NAMES = ["v_affine", "v_skew", "v_axes", "v_tvar", "v_axisvar_noaxes", "v_nested", + "v_reset1", "v_cond", "v_reserved", "v_inner", "v_outer", "v_inner_keep", "v_outer_keep", + "v_bigaxis"] + +def make(g): + R_A = [(0, 0.0, 1.0, 1.0)] + R_B = [(1, 0.0, 1.0, 1.0)] + R_AB = [(0, 0.0, 1.0, 1.0), (1, 0.0, 1.0, 1.0)] + store = multi_var_store( + [R_A, R_B, R_AB], + [([0, 1], [ + [1000, 2000, -3000, 500], # 0: two axis values, region-major + [50, -20, 410, 256, -30, 60, 0, -128], # 1: TX TY ROT SX + [2, 0], # 2: one value (condition) + [1500, -800], # 3: one axis value + ]), + ([2], [[700, -300]])], # outer 1: two values, one region + ) + conds = condition_list([ + cond_axis(0, 0.2, 1.0), + cond_value(-1, 2), + cond_list_op(3, [cond_axis(1, -1.0, 0.0), cond_not(cond_axis(2, 0.5, 1.0))]), + cond_list_op(4, [cond_axis(0, -1.0, -0.5), cond_axis(1, 0.5, 1.0)]), + ]) + axis_lists = [[0, 2], [1], [0], [5000]] + box, tri = g["box"], g["tri"] + rec = {} + rec["v_affine"] = component(box, fields=dict(TX=10, TY=20, ROT=683, SX=1536)) + rec["v_skew"] = component(box, fields=dict(SX=820, SY=1229, SKX=410, SKY=-205, CX=50, CY=50)) + rec["v_axes"] = component(box, axes=(0, [8192, -4096]), axis_var=0, fields=dict(TX=5)) + rec["v_tvar"] = component(box, transform_var=1, fields=dict(TX=100, TY=0, ROT=0, SX=1024)) + rec["v_axisvar_noaxes"] = component(box, axis_var=0, fields=dict(TX=200)) + rec["v_nested"] = component(g["v_axes"], axes=(1, [4915]), fields=dict(TX=300)) + \ + component(tri, axes=(0, [-8192, 8192]), axis_var=(1 << 16), fields=dict(TX=500, ROT=-410)) + rec["v_reset1"] = component(box, reset=True, axes=(2, [3277])) + rec["v_cond"] = (component(box, cond=0) + component(tri, cond=1, fields=dict(TX=150)) + + component(box, cond=2, fields=dict(TX=300)) + + component(tri, cond=3, fields=dict(TX=450)) + + component(box, cond=99, fields=dict(TX=600)) + + component(tri, fields=dict(TX=750))) + rec["v_reserved"] = component(box, fields=dict(TX=10), reserved=[(15, 5), (20, 300)]) + \ + component(tri, fields=dict(TX=500)) + rec["v_inner"] = component(box, reset=True, axes=(2, [3277])) + rec["v_outer"] = component(g["v_inner"], axes=(1, [11469]), fields=dict(TY=10)) + rec["v_inner_keep"] = component(box, axes=(2, [3277])) + rec["v_outer_keep"] = component(g["v_inner_keep"], axes=(1, [11469]), fields=dict(TY=10)) + # Axis index 5000 is past HarfBuzz's 4096-axis limit: ignored. + rec["v_bigaxis"] = component(box, axes=(3, [8192]), fields=dict(TX=1)) + gids = sorted((g[n], rec[n]) for n in VARC_NAMES) + return varc_table([x for x, _ in gids], [r for _, r in gids], store, conds, axis_lists) + +# --- morx ----------------------------------------------------------------- + +GLYPHS = [".notdef", "a", "b", "f", "i", "l", "x", "A", "B", "f_i", "f_f", "f_f_i", "l_x", "X", "c"] +G = {n: i for i, n in enumerate(GLYPHS)} +CMAP = {ord(c): c for c in "abfilxc"} + +def lookup6(pairs): + pairs = sorted(pairs) + out = struct.pack(">HHHHHH", 6, 4, len(pairs), 0, 0, 0) + for g, v in pairs: + out += struct.pack(">HH", g, v) + return out + +def stx(n_classes, class_pairs, states, entries, ext): + """Extended state table body. states: rows of entry indices. + entries: packed entry bytes. ext: list of byte blobs, each placed + after the arrays and referenced by a u32 offset after the header.""" + head = 16 + 4 * len(ext) + classes = lookup6(class_pairs) + class_off = head + state_off = class_off + len(classes) + if state_off % 2: + state_off += 1 + state_bytes = b"".join(struct.pack(">H", e) for row in states for e in row) + entry_off = state_off + len(state_bytes) + entry_bytes = b"".join(entries) + body_tail_off = entry_off + len(entry_bytes) + ext_offs = [] + tail = b"" + for blob in ext: + while (body_tail_off + len(tail)) % 4: + tail += b"\0" + ext_offs.append(body_tail_off + len(tail)) + tail += blob + out = struct.pack(">IIII", n_classes, class_off, state_off, entry_off) + out += b"".join(struct.pack(">I", o) for o in ext_offs) + out += classes + out += b"\0" * (state_off - class_off - len(classes)) + out += state_bytes + entry_bytes + tail + return out + +def subtable(kind, body, flags=1): + return struct.pack(">III", 12 + len(body), kind, flags) + body + +def morx(subtables): + body = b"".join(subtables) + chain = struct.pack(">IIII", 1, 16 + len(body), 0, len(subtables)) + body + return struct.pack(">HHI", 2, 0, 1) + chain + +def build_morx_font(path, morx_bytes): + fb = FontBuilder(1000, isTTF=True) + fb.setupGlyphOrder(GLYPHS) + fb.setupCharacterMap(CMAP) + pen = TTGlyphPen(None) + fb.setupGlyf({n: pen.glyph() for n in GLYPHS}) + fb.setupHorizontalMetrics({n: (100 + 10 * i, 0) for i, n in enumerate(GLYPHS)}) + fb.setupHorizontalHeader(ascent=800, descent=-200) + fb.setupNameTable({"familyName": "MorxTest", "styleName": "Regular"}) + fb.setupOS2() + fb.setupPost() + t = DefaultTable("morx") + t.data = morx_bytes + fb.font["morx"] = t + fixed_timestamps(fb) + fb.save(path) + +# Entry flags. +SET_MARK = 0x8000 +DONT_ADVANCE = 0x4000 +SET_COMPONENT = 0x8000 +PERFORM_ACTION = 0x2000 +LAST = 0x80000000 +STORE = 0x40000000 + +def ctx_entry(new_state, flags, mark, current): + return struct.pack(">HHHH", new_state, flags, mark, current) + +def lig_entry(new_state, flags, action): + return struct.pack(">HHH", new_state, flags, action) + +def lig_action(offset, last=False, store=False): + return struct.pack(">I", (offset & 0x3FFFFFFF) | (LAST if last else 0) | (STORE if store else 0)) + +def contextual(): + # Classes: a=4, b=5, x=6, c=7. + classes = [(G["a"], 4), (G["b"], 5), (G["x"], 6), (G["c"], 7)] + N = 0xFFFF + entries = [ + ctx_entry(0, 0, N, N), # 0 noop + ctx_entry(2, SET_MARK, N, N), # 1 a: mark it + ctx_entry(0, 0, 1, 0), # 2 b after a: a->A (mark), b->B (current) + ctx_entry(3, SET_MARK, N, N), # 3 x: mark, wait for end of text + ctx_entry(0, 0, N, 2), # 4 end of text after x: current -> X + ctx_entry(0, 0, 3, N), # 5 c with no mark set: mark lookup 3 (c->A) + ] + states = [ + [0, 0, 0, 0, 1, 0, 3, 5], + [0, 0, 0, 0, 1, 0, 3, 5], + [0, 0, 0, 0, 1, 2, 3, 5], + [4, 0, 0, 0, 1, 0, 3, 5], + ] + lookups = [lookup6([(G["b"], G["B"])]), lookup6([(G["a"], G["A"])]), + lookup6([(G["x"], G["X"])]), lookup6([(G["c"], G["A"]), (G["a"], G["B"])])] + # Unsized array of u32 offsets from the table start, then the lookups. + offs = b"" + at = 4 * len(lookups) + for lk in lookups: + offs += struct.pack(">I", at); at += len(lk) + table = offs + b"".join(lookups) + return subtable(1, stx(8, classes, states, entries, [table])) + +def ligatures(): + classes = [(G["f"], 4), (G["i"], 5), (G["l"], 6), (G["x"], 7), (G["a"], 8), (G["b"], 9), (G["c"], 10), (G["f_f"], 4)] + ligs = [G["f_i"], G["f_f"], G["f_f_i"], G["l_x"], G["X"], G["A"], G["B"]] + comps = [0] * 1400 + def comp(off, glyph, value): + comps[off + glyph] = value + actions = b"" + def action_list(pairs): + nonlocal actions + start = len(actions) // 4 + for off, last, store in pairs: + actions += lig_action(off, last, store) + return start + A_fi = action_list([(100, False, False), (200, True, True)]); comp(100, G["i"], 0); comp(200, G["f"], 0) + A_ff = action_list([(300, False, False), (400, True, True)]); comp(300, G["f"], 1); comp(400, G["f"], 0) + A_ffi = action_list([(500, False, False), (600, True, True)]); comp(500, G["i"], 2); comp(600, G["f_f"], 0) + A_lx = action_list([(700, False, False), (800, True, True)]); comp(700, G["x"], 3); comp(800, G["l"], 0) + A_ab = action_list([(900, False, False), (1000, True, True)]); comp(900, G["b"], 4); comp(1000, G["a"], 0) + A_cc = action_list([(1100, False, True), (1200, True, False)]); comp(1100, G["c"], 5); comp(1200, G["c"], 1) + SC, PA, DA = SET_COMPONENT, PERFORM_ACTION, DONT_ADVANCE + entries = [ + lig_entry(0, 0, 0), # 0 noop + lig_entry(2, SC, 0), # 1 f: push + lig_entry(3, SC | PA, A_ff), # 2 f after f: ff, ligature stays on the stack + lig_entry(0, SC | PA, A_fi), # 3 i after f: fi + lig_entry(0, SC | PA, A_ffi), # 4 i after ff: ffi + lig_entry(4, SC, 0), # 5 l: push + lig_entry(5, SC | DA, 0), # 6 x after l: push, stay + lig_entry(0, SC | PA, A_lx), # 7 x again: push (same glyph), lx + lig_entry(6, SC, 0), # 8 a: push + lig_entry(7, SC, 0), # 9 b after a: push + lig_entry(0, PA, A_ab), # 10 end of text after a b: act + lig_entry(8, SC, 0), # 11 c: push + lig_entry(0, SC | PA, A_cc), # 12 c after c: two stores + ] + # EOT OOB DEL EOL f i l x a b c + s0 = [0, 0, 0, 0, 1, 0, 5, 0, 8, 0, 11] + states = [ + s0, s0, + [0, 0, 0, 0, 2, 3, 5, 0, 8, 0, 11], # 2 after f + [0, 0, 0, 0, 1, 4, 5, 0, 8, 0, 11], # 3 after ff + [0, 0, 0, 0, 1, 0, 5, 6, 8, 0, 11], # 4 after l + [0, 0, 0, 0, 1, 0, 5, 7, 8, 0, 11], # 5 after l x (x not advanced) + [0, 0, 0, 0, 1, 0, 5, 0, 8, 9, 11], # 6 after a + [10, 0, 0, 0, 1, 0, 5, 0, 8, 0, 11], # 7 after a b + [0, 0, 0, 0, 1, 0, 5, 0, 8, 0, 12], # 8 after c + ] + comp_bytes = b"".join(struct.pack(">H", c) for c in comps) + lig_bytes = b"".join(struct.pack(">H", g) for g in ligs) + return subtable(2, stx(11, classes, states, entries, [actions, comp_bytes, lig_bytes])) + + +def main() -> None: + build_varc_font(FIXTURES / "varc_parity.ttf", VARC_NAMES, make) + build_morx_font(FIXTURES / "aat_morx_contextual.ttf", morx([contextual()])) + build_morx_font(FIXTURES / "aat_morx_ligature.ttf", morx([ligatures()])) + + +if __name__ == "__main__": + main() diff --git a/tests/tools/varc_morx_parity_expected.py b/tests/tools/varc_morx_parity_expected.py new file mode 100644 index 00000000..4d55767a --- /dev/null +++ b/tests/tools/varc_morx_parity_expected.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Record HarfBuzz's output for the VARC and morx parity fixtures. + +Writes tests/fixtures/varc_parity.expected, the outline of every VARC +glyph of varc_parity.ttf at eight locations, and +tests/fixtures/aat_morx_parity.expected, the glyphs and clusters +HarfBuzz's AAT shaper gives for strings run through +aat_morx_contextual.ttf and aat_morx_ligature.ttf. Build the fonts with +tests/tools/build_varc_morx_parity_fixtures.py first. + +Run: + uv run --no-project --with uharfbuzz==0.56.2 --with fonttools python tests/tools/varc_morx_parity_expected.py +""" + +from __future__ import annotations + +from pathlib import Path + +import uharfbuzz as hb +from fontTools.pens.recordingPen import RecordingPen +from fontTools.ttLib import TTFont + +FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" + +LOCATIONS = [ + "default", + "AAAA=50,BBBB=-30,CCCC=70", + "AAAA=33.3,BBBB=66.7,CCCC=-12.5", + "AAAA=-80,BBBB=90,CCCC=55", + "AAAA=70,BBBB=-100,CCCC=20", + "AAAA=100,BBBB=100,CCCC=-100", + "AAAA=25,BBBB=0,CCCC=0", + "AAAA=-50,BBBB=10,CCCC=60", +] + +MORX_STRINGS = { + "aat_morx_contextual.ttf": ["ab", "ba", "aab", "abab", "x", "xa", "cx", "ca", "acb", "xx", "bx"], + "aat_morx_ligature.ttf": [ + "fi", "ff", "ffi", "fff", "fffi", "ffffi", "lx", "lxlx", "xlx", + "ab", "abc", "cc", "ccc", "fiab", + ], +} + + +def num(v: float) -> str: + text = f"{v:.4f}".rstrip("0").rstrip(".") + return "0" if text in ("-0", "") else text + + +def outline(font: hb.Font, gid: int) -> str: + pen = RecordingPen() + font.draw_glyph_with_pen(gid, pen) + ops = [] + start = None + for op, args in pen.value: + if op == "moveTo": + start = args[0] + ops.append(("M", [*args[0]])) + elif op == "lineTo": + ops.append(("L", [*args[0]])) + elif op == "qCurveTo": + ops.append(("Q", [*args[0], *args[-1]])) + elif op == "curveTo": + ops.append(("C", [*args[0], *args[1], *args[2]])) + elif op == "closePath": + # HarfBuzz closes with a line back to the start; sigilbuzz's + # Close implies it. + if ops and ops[-1][0] == "L" and start is not None and tuple(ops[-1][1]) == tuple(start): + ops.pop() + ops.append(("Z", [])) + return " ".join(" ".join([op] + [num(v) for v in vals]) for op, vals in ops) + + +def varc() -> None: + path = FIXTURES / "varc_parity.ttf" + tt = TTFont(path) + order = tt.getGlyphOrder() + covered = tt["VARC"].table.Coverage.glyphs + face = hb.Face(hb.Blob.from_file_path(str(path))) + lines = [ + f"# HarfBuzz {hb.version_string()} (uharfbuzz {hb.__version__}) outlines of varc_parity.ttf.", + "# outline : M/L x y, Q cx cy x y, C ..., Z.", + ] + for loc in LOCATIONS: + font = hb.Font(face) + if loc != "default": + font.set_variations({kv.split("=")[0]: float(kv.split("=")[1]) for kv in loc.split(",")}) + for name in covered: + gid = order.index(name) + lines.append(f"outline {loc} {gid} {name} {outline(font, gid)}") + (FIXTURES / "varc_parity.expected").write_text("\n".join(lines) + "\n", newline="\n") + + +def morx() -> None: + lines = [ + f"# HarfBuzz {hb.version_string()} (uharfbuzz {hb.__version__}) AAT shaping of the morx fixtures.", + "# shape :...", + ] + for name, strings in MORX_STRINGS.items(): + font = hb.Font(hb.Face(hb.Blob.from_file_path(str(FIXTURES / name)))) + for text in strings: + buf = hb.Buffer() + buf.add_str(text) + buf.guess_segment_properties() + hb.shape(font, buf) + glyphs = " ".join(f"{i.codepoint}:{i.cluster}" for i in buf.glyph_infos) + lines.append(f"shape {name} {text} {glyphs}") + (FIXTURES / "aat_morx_parity.expected").write_text("\n".join(lines) + "\n", newline="\n") + + +if __name__ == "__main__": + varc() + morx() diff --git a/tests/varc_parity.rs b/tests/varc_parity.rs new file mode 100644 index 00000000..ae3f7daa --- /dev/null +++ b/tests/varc_parity.rs @@ -0,0 +1,183 @@ +//! VARC outlines against HarfBuzz. +//! +//! `tests/fixtures/varc_parity.ttf` has three axes and 14 VARC glyphs +//! over two `gvar`-varied glyphs. Between them they use every component +//! record field: transforms (ScaleY defaulting to ScaleX, skew and a +//! transformation center), axis values with deltas over several regions, +//! transform deltas, an axis-values index without axes, a nested +//! composite, conditions of all five formats, reserved flag bits, +//! RESET_UNSPECIFIED_AXES at the top and one level down, and an axis +//! index past HarfBuzz's 4096-axis limit. +//! +//! `tests/fixtures/varc_parity.expected` holds HarfBuzz 14.5.0's outline +//! of each glyph at eight locations. `tests/tools/build_varc_morx_parity_fixtures.py` +//! builds the font and `tests/tools/varc_morx_parity_expected.py` writes +//! the expected file. + +use sigilbuzz::tables::PathOp; +use sigilbuzz::{Blob, Face}; + +const FONT: &[u8] = include_bytes!("fixtures/varc_parity.ttf"); +const EXPECTED: &str = include_str!("fixtures/varc_parity.expected"); + +/// The glyph whose RESET_UNSPECIFIED_AXES component sits one composite +/// down. `Face::glyph_outline_at_coords` hands nested composites the +/// component's coords as the font's, so it is checked through +/// `Varc::composite_with_font_coords` instead. +const NESTED_RESET: &str = "v_outer"; + +type Ops = Vec<(char, Vec)>; + +fn parse_ops(text: &str) -> Ops { + let mut tokens = text.split(' ').filter(|t| !t.is_empty()); + let mut out = Vec::new(); + while let Some(op) = tokens.next() { + let op = op.chars().next().unwrap(); + let n = match op { + 'M' | 'L' => 2, + 'Q' => 4, + 'C' => 6, + _ => 0, + }; + let vals = (0..n) + .map(|_| tokens.next().unwrap().parse().unwrap()) + .collect(); + out.push((op, vals)); + } + out +} + +/// sigilbuzz's ops in the expected file's form: a closing line back to +/// the contour's start is left to `Z`. +fn ops_of(path: &[PathOp]) -> Ops { + let mut out: Ops = Vec::new(); + let mut start = (0.0, 0.0); + for op in path { + match *op { + PathOp::MoveTo { x, y } => { + start = (x, y); + out.push(('M', vec![x, y])); + } + PathOp::LineTo { x, y } => out.push(('L', vec![x, y])), + PathOp::QuadTo { cx, cy, x, y } => out.push(('Q', vec![cx, cy, x, y])), + PathOp::CubicTo { + c1x, + c1y, + c2x, + c2y, + x, + y, + } => out.push(('C', vec![c1x, c1y, c2x, c2y, x, y])), + PathOp::Close => { + if out.last() == Some(&('L', vec![start.0, start.1])) { + out.pop(); + } + out.push(('Z', Vec::new())); + } + } + } + out +} + +/// Normalized coords at `location` (`default` or `TAG=v,...`), rounded +/// to F2DOT14 as HarfBuzz and `Face` keep them. +fn coords_at(face: &Face<'_>, location: &str) -> Vec { + let fvar = face.fvar().unwrap().unwrap(); + let mut user: Vec = fvar.axes().iter().map(|a| a.default_value).collect(); + if location != "default" { + for kv in location.split(',') { + let (tag, v) = kv.split_once('=').unwrap(); + let i = fvar + .axes() + .iter() + .position(|a| a.tag == tag.as_bytes()) + .unwrap(); + user[i] = v.parse().unwrap(); + } + } + let coords: Vec = fvar + .normalize_coords(&user) + .iter() + .map(|c| (c * 16384.0 + 0.5).floor() / 16384.0) + .collect(); + if coords.iter().all(|&c| c == 0.0) { + Vec::new() + } else { + coords + } +} + +fn mul(p: [f32; 6], c: [f32; 6]) -> [f32; 6] { + [ + p[0] * c[0] + p[1] * c[2], + p[0] * c[1] + p[1] * c[3], + p[2] * c[0] + p[3] * c[2], + p[2] * c[1] + p[3] * c[3], + p[0] * c[4] + p[1] * c[5] + p[4], + p[2] * c[4] + p[3] * c[5] + p[5], + ] +} + +/// Draws `gid` the way HarfBuzz's VARC walk does, passing the font's +/// coords down so nested RESET_UNSPECIFIED_AXES components start from +/// them. Leaves come from `Face::glyph_outline_at_coords`. +fn flatten(face: &Face<'_>, gid: u16, coords: &[f32], font: &[f32], t: [f32; 6], out: &mut Ops) { + let varc = face.varc().unwrap().unwrap(); + if varc.covers(gid) { + let composite = varc.composite_with_font_coords(gid, coords, font).unwrap(); + for c in &composite.components { + flatten(face, c.gid, &c.coords, font, mul(t, c.transform), out); + } + return; + } + let Some(outline) = face.glyph_outline_at_coords(gid, coords).unwrap() else { + return; + }; + for (op, vals) in ops_of(outline.ops()) { + let mut mapped = Vec::with_capacity(vals.len()); + for p in vals.chunks(2) { + mapped.push(t[0] * p[0] + t[1] * p[1] + t[4]); + mapped.push(t[2] * p[0] + t[3] * p[1] + t[5]); + } + out.push((op, mapped)); + } +} + +#[track_caller] +fn assert_close(got: &Ops, want: &Ops, what: &str) { + let kinds = |ops: &Ops| ops.iter().map(|(op, _)| *op).collect::(); + assert_eq!(kinds(got), kinds(want), "{what}: ops"); + for ((_, g), (_, w)) in got.iter().zip(want) { + for (a, b) in g.iter().zip(w) { + assert!((a - b).abs() < 0.01, "{what}: {g:?} vs HarfBuzz {w:?}"); + } + } +} + +#[test] +fn varc_outlines_match_harfbuzz() { + let blob = Blob::new(FONT); + let face = Face::parse(&blob, 0).unwrap(); + let mut checked = 0; + for line in EXPECTED.lines().filter(|l| l.starts_with("outline ")) { + let mut fields = line.splitn(5, ' ').skip(1); + let location = fields.next().unwrap(); + let gid: u16 = fields.next().unwrap().parse().unwrap(); + let name = fields.next().unwrap(); + let want = parse_ops(fields.next().unwrap_or("")); + let coords = coords_at(&face, location); + let what = format!("{name} at {location}"); + let got = if name == NESTED_RESET { + let mut ops = Vec::new(); + let identity = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + flatten(&face, gid, &coords, &coords, identity, &mut ops); + ops + } else { + let outline = face.glyph_outline_at_coords(gid, &coords).unwrap(); + outline.map_or_else(Vec::new, |o| ops_of(o.ops())) + }; + assert_close(&got, &want, &what); + checked += 1; + } + assert_eq!(checked, 14 * 8); +} From 22355b8a1eda25d3354d3b6299a0372573f6eb90 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 11:32:49 -0800 Subject: [PATCH 10/13] Reject a second frame in a JPEG stream 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. --- crates/sigilbuzz-render/src/jpeg_decode.rs | 32 +++++++++++++------ .../src/jpeg_decode/tests/adversarial.rs | 32 +++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/crates/sigilbuzz-render/src/jpeg_decode.rs b/crates/sigilbuzz-render/src/jpeg_decode.rs index 254cdba5..15b894d9 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode.rs @@ -8,11 +8,11 @@ //! - **8-bit precision only.** All real-world font sbix JPEGs are 8-bit. //! - **Baseline sequential DCT (SOF0)** plus **progressive DCT (SOF2)** //! with spectral selection and successive approximation: first DC -//! and AC scans and DC and AC refinement scans (T.81 G.1.2). All the -//! scans of a progressive frame together may visit at most 1024 -//! coefficients per 8x8 block, which a valid scan script never -//! needs; a stream past that is [`RenderError::BadJpeg`]. No +//! and AC scans and DC and AC refinement scans (T.81 G.1.2). No //! arithmetic coding (SOF9..15), no hierarchical (SOFE). +//! - **One frame per stream.** A second SOF is +//! [`RenderError::BadJpeg`], as in libjpeg, so the scan budget below +//! bounds the whole stream. //! - **YCbCr** (3-component) and **grayscale** (1-component). //! - **Sampling factors:** 4:4:4, 4:2:2, 4:2:0, and any combination //! where each component's max sampling factor is `<= 2`. @@ -25,6 +25,11 @@ //! of entropy-coded data, so a frame header that declares more //! blocks than eight per remaining input byte is rejected before //! any sample buffer is allocated. +//! - **Bounded scan work.** The scans of the stream's frame together +//! may visit at most 1024 coefficients per 8x8 block, which a valid +//! scan script never needs (each coefficient coded once and refined +//! at most 13 times costs 896); a stream past that is +//! [`RenderError::BadJpeg`]. //! //! Output is a premultiplied RGBA [`ColorPixmap`] with alpha = 255 //! (JPEG has no transparency channel). @@ -95,13 +100,15 @@ const MAX_HUFF_TABLES: usize = 4; /// Hard cap on quantization table count. Same logic: 4 destinations. const MAX_QT_TABLES: usize = 4; -/// Coefficient visits a progressive frame's scans may make per 8x8 -/// block, summed over every scan: a scan of band `Ss..=Se` costs +/// Coefficient visits the scans of a stream may make per 8x8 block of +/// its one frame, summed over every scan: a scan of band `Ss..=Se` costs /// `Se - Ss + 1` per block it covers. A valid script codes each bit of /// each coefficient once, which costs at most 64 * 14 = 896 (the first /// scan plus one refinement per bit, `Al <= 13`), so only a stream /// that repeats scans runs out. The cap keeps a few bytes per scan -/// from making the decoder walk every block again and again. +/// from making the decoder walk every block again and again; since a +/// stream holds one frame, it bounds the whole stream at 1024 visits +/// per block. const SCAN_WORK_PER_BLOCK: u64 = 1024; // --------------------------------------------------------------------------- @@ -116,8 +123,8 @@ const SCAN_WORK_PER_BLOCK: u64 = 1024; /// max h/v is `<= 2`. Both baseline (SOF0) and progressive (SOF2, /// spectral selection and successive approximation, refinement scans /// included) modes are handled. Arithmetic coding, 16-bit precision, -/// hierarchical mode, restart markers, and JPEG2000 / TIFF return -/// [`RenderError::BadJpeg`]. +/// hierarchical mode, restart markers, a second frame, and JPEG2000 / +/// TIFF return [`RenderError::BadJpeg`]. /// /// # Errors /// Returns [`RenderError::BadJpeg`] on any structural problem, @@ -371,6 +378,13 @@ impl<'a> Decoder<'a> { } fn read_sof(&mut self, progressive: bool) -> Result<(), RenderError> { + // A stream holds one frame. libjpeg stops at a second SOF + // (JERR_SOF_DUPLICATE); so does this decoder, which would + // otherwise size and walk a fresh frame, with a fresh scan + // budget, for every SOF a stream repeats. + if !self.components.is_empty() { + return Err(RenderError::BadJpeg("second SOF")); + } let body = self.read_segment()?; if body.len() < 6 { return Err(RenderError::BadJpeg("truncated SOF")); diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs b/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs index 61ed4ec1..2b243083 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs @@ -333,3 +333,35 @@ fn idct_cos_table_matches_inline_cosines() { assert_eq!(a, b); } } + +#[test] +fn a_second_frame_is_an_error() { + // A stream holds one frame. Each SOF used to size a fresh frame + // with a fresh scan budget, so a stream of frames multiplied the + // work, as libjpeg's JERR_SOF_DUPLICATE prevents. + use super::encode::{encode_progressive, libjpeg_script, Image}; + let img = Image::random(16, 16, &[(1, 1)], 9, 40, 3); + let one = encode_progressive(&img, &libjpeg_script(1)); + let sof = marker_pos(&one, MARKER_SOF2) - 1; + let len = usize::from(u16::from_be_bytes([one[sof + 2], one[sof + 3]])); + let frame_header = one[sof..sof + 2 + len].to_vec(); + // The header again after the last scan, before EOI. + let mut two = one[..one.len() - 2].to_vec(); + two.extend_from_slice(&frame_header); + two.extend_from_slice(&[0xFF, MARKER_EOI]); + assert!(decode_jpeg(&one).is_ok()); + assert_eq!( + decode_jpeg(&two).unwrap_err(), + RenderError::BadJpeg("second SOF") + ); + // A baseline header repeated before its scan. + let base = build_constant_jpeg(0, 0, 0); + let sof = marker_pos(&base, MARKER_SOF0) - 1; + let len = usize::from(u16::from_be_bytes([base[sof + 2], base[sof + 3]])); + let mut twice = base[..sof + 2 + len].to_vec(); + twice.extend_from_slice(&base[sof..]); + assert_eq!( + decode_jpeg(&twice).unwrap_err(), + RenderError::BadJpeg("second SOF") + ); +} From d811ab6cbe701ca43319270e421a805ca6e1c748 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 11:34:34 -0800 Subject: [PATCH 11/13] Back a JPEG frame by its scan data and cap it at 2^24 pixels 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. --- crates/sigilbuzz-render/src/jpeg_decode.rs | 87 ++++++++++++++++--- .../src/jpeg_decode/tests/adversarial.rs | 82 +++++++++++++++++ .../src/jpeg_decode/tests/progressive.rs | 3 + 3 files changed, 162 insertions(+), 10 deletions(-) diff --git a/crates/sigilbuzz-render/src/jpeg_decode.rs b/crates/sigilbuzz-render/src/jpeg_decode.rs index 15b894d9..cd2bd034 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode.rs @@ -23,8 +23,11 @@ //! [`RenderError::BadJpeg`]. //! - **Size backed by data.** Every 8x8 block costs at least one bit //! of entropy-coded data, so a frame header that declares more -//! blocks than eight per remaining input byte is rejected before -//! any sample buffer is allocated. +//! blocks than eight per byte of entropy-coded data in the scans +//! after it (COM, APPn and other segments do not count) is rejected +//! before any sample buffer is allocated. +//! - **At most 2^24 pixels** (4096 x 4096) per frame, which keeps one +//! decode near 220 MB; see `MAX_JPEG_PIXELS`. //! - **Bounded scan work.** The scans of the stream's frame together //! may visit at most 1024 coefficients per 8x8 block, which a valid //! scan script never needs (each coefficient coded once and refined @@ -93,6 +96,16 @@ const MARKER_COM: u8 = 0xFE; /// blow up `usize` math via a 65535x65535 SOF0. const MAX_JPEG_DIM: u32 = 16384; +/// Most pixels a frame may hold: 2^24, 4096 x 4096. JPEG data in a +/// font is an sbix glyph image, drawn for one strike's ppem, and a +/// strike that large would be sixteen times the area of a 1024 ppem +/// one. 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 the cap keeps one decode near +/// 220 MB. Without it a stream with enough entropy-coded data could ask +/// for 16384 x 16384, about 3.5 GB. +const MAX_JPEG_PIXELS: u64 = 1 << 24; + /// Hard cap on Huffman table count. JPEG allows 4 of each AC/DC class, /// so the worst legitimate case is 8 tables. const MAX_HUFF_TABLES: usize = 4; @@ -108,7 +121,8 @@ const MAX_QT_TABLES: usize = 4; /// that repeats scans runs out. The cap keeps a few bytes per scan /// from making the decoder walk every block again and again; since a /// stream holds one frame, it bounds the whole stream at 1024 visits -/// per block. +/// per block, and with at most eight blocks per byte of scan data, at +/// 8192 visits per byte. const SCAN_WORK_PER_BLOCK: u64 = 1024; // --------------------------------------------------------------------------- @@ -123,7 +137,8 @@ const SCAN_WORK_PER_BLOCK: u64 = 1024; /// max h/v is `<= 2`. Both baseline (SOF0) and progressive (SOF2, /// spectral selection and successive approximation, refinement scans /// included) modes are handled. Arithmetic coding, 16-bit precision, -/// hierarchical mode, restart markers, a second frame, and JPEG2000 / +/// hierarchical mode, restart markers, a second frame, a frame past +/// 2^24 pixels or larger than its scan data can back, and JPEG2000 / /// TIFF return [`RenderError::BadJpeg`]. /// /// # Errors @@ -438,10 +453,11 @@ impl<'a> Decoder<'a> { // Every block the scans will visit costs at least one bit of // entropy-coded data (a DC code in the first scan), and that - // data follows this segment. A header that claims more blocks - // than the rest of the stream can carry is malformed, and - // rejecting it here keeps a few header bytes from sizing - // gigabytes of coefficient and sample buffers. + // data follows this segment inside SOS segments. A header that + // claims more blocks than the scans after it carry is + // malformed, and rejecting it here keeps a few header bytes, + // or a header padded out with COM or APPn segments, from + // sizing gigabytes of coefficient and sample buffers. let (mcus_x, mcus_y) = self.mcu_grid(); let total_blocks: u64 = self .components @@ -453,10 +469,13 @@ impl<'a> Decoder<'a> { * u64::from(c.v_sampling) }) .sum(); - let remaining = self.src.len().saturating_sub(self.cursor) as u64; - if total_blocks > remaining.saturating_mul(8) { + let entropy = entropy_bytes_after(self.src, self.cursor); + if total_blocks > entropy.saturating_mul(8) { return Err(RenderError::BadJpeg("frame larger than entropy data")); } + if u64::from(self.width) * u64::from(self.height) > MAX_JPEG_PIXELS { + return Err(RenderError::BadJpeg("frame larger than pixel limit")); + } if progressive { self.allocate_progressive_buffers(); @@ -513,6 +532,54 @@ impl<'a> Decoder<'a> { // Tests. // --------------------------------------------------------------------------- +/// Bytes of entropy-coded data in the SOS segments from `at` on. Marker +/// segments are skipped by their lengths; after each SOS header the +/// entropy-coded bytes run to the next marker (an `0xFF` not followed +/// by a stuffed `0x00`). The walk stops at EOI, at a byte where a +/// marker should start, or at the end of `src`, so COM, APPn, and +/// other padding never count. Linear in the bytes it walks. +fn entropy_bytes_after(src: &[u8], mut at: usize) -> u64 { + let mut total = 0u64; + loop { + if src.get(at) != Some(&0xFF) { + return total; + } + while src.get(at) == Some(&0xFF) { + at += 1; + } + let Some(&marker) = src.get(at) else { + return total; + }; + at += 1; + match marker { + MARKER_EOI | 0x00 => return total, + // Markers without a length: RSTn and TEM. + 0xD0..=0xD7 | 0x01 => continue, + _ => { + let Some(&[hi, lo]) = src.get(at..).and_then(|s| s.first_chunk::<2>()) else { + return total; + }; + let len = usize::from(u16::from_be_bytes([hi, lo])); + if len < 2 { + return total; + } + at += len; + if marker != MARKER_SOS { + continue; + } + let start = at.min(src.len()); + while let Some(&b) = src.get(at) { + if b == 0xFF && src.get(at + 1) != Some(&0x00) { + break; + } + at += if b == 0xFF { 2 } else { 1 }; + } + total += (at.min(src.len()) - start) as u64; + } + } + } +} + /// Table in slot `selector`, or `None` when the slot is empty or the /// selector points past the four slots the format defines. Scan /// headers carry 4-bit selectors, so values up to 15 reach this. diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs b/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs index 2b243083..82201606 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs @@ -86,6 +86,7 @@ fn marker_walker_rejects_progressive_scan_params() { bytes.push(1); // n_scan bytes.extend_from_slice(&[1, 0x00]); // comp id, td/ta=0 bytes.extend_from_slice(&[1, 63, 0]); // Ss=1 (bad), Se, Ah/Al + bytes.push(0x00); // one byte of scan data let err = decode_jpeg(&bytes).unwrap_err(); assert!( matches!(err, RenderError::BadJpeg("non-baseline scan parameters")), @@ -124,6 +125,7 @@ fn marker_walker_rejects_refinement_bit_field() { bytes.push(1); bytes.extend_from_slice(&[1, 0x00]); bytes.extend_from_slice(&[0, 63, 0x11]); // Ss=0, Se=63, Ah/Al=0x11 + bytes.push(0x00); // one byte of scan data let err = decode_jpeg(&bytes).unwrap_err(); assert!( matches!(err, RenderError::BadJpeg("non-baseline scan parameters")), @@ -334,6 +336,86 @@ fn idct_cos_table_matches_inline_cosines() { } } +/// An SOS header for one component and `data` bytes of scan data. +fn scan(data: &[u8]) -> Vec { + let mut out = vec![0xFF, MARKER_SOS, 0x00, 8, 1, 1, 0x00, 0, 63, 0]; + out.extend_from_slice(data); + out +} + +/// A COM segment with `len` bytes of comment. +fn comment(len: usize) -> Vec { + let mut out = vec![0xFF, MARKER_COM]; + out.extend_from_slice(&(len as u16 + 2).to_be_bytes()); + out.resize(out.len() + len, 0); + out +} + +#[test] +fn only_scan_data_backs_a_frame() { + // A 2048x2048 grayscale frame has 65,536 blocks, so its scans need + // at least 8,192 bytes. COM padding after the frame header used to + // count, so a few KB of comments let the header size about 300 MB + // of buffers. + let mut tail = comment(9000); + tail.extend(scan(&[0])); + tail.extend_from_slice(&[0xFF, MARKER_EOI]); + assert_eq!( + decode_jpeg(&frame_only(MARKER_SOF0, 2048, 2048, &tail)).unwrap_err(), + RenderError::BadJpeg("frame larger than entropy data") + ); + // 8,192 bytes of scan data pass the check; with no Huffman tables + // the scan then fails before any buffer is sized. + let mut tail = comment(9000); + tail.extend(scan(&vec![0x11; 8192])); + tail.extend_from_slice(&[0xFF, MARKER_EOI]); + assert_eq!( + decode_jpeg(&frame_only(MARKER_SOF0, 2048, 2048, &tail)).unwrap_err(), + RenderError::BadJpeg("missing DC Huffman table") + ); +} + +#[test] +fn entropy_bytes_count_only_scan_data() { + let mut s = vec![0xFF, MARKER_SOI]; + let at = s.len(); + s.extend(comment(100)); + s.extend(scan(&[1, 2, 0xFF, 0x00, 3])); // a stuffed 0xFF counts as two + s.extend_from_slice(&[0xFF, MARKER_DHT, 0x00, 4, 0, 0]); + s.extend(scan(&[4, 5])); + s.extend_from_slice(&[0xFF, 0xFF, MARKER_EOI]); // fill before EOI + s.extend(scan(&[6; 50])); // after EOI: not counted + assert_eq!(entropy_bytes_after(&s, at), 7); + // A byte where a marker should start ends the walk. + assert_eq!(entropy_bytes_after(&[0x00, 0xFF, MARKER_SOS], 0), 0); + // A scan that runs to the end of the data counts to the end; a + // last lone 0xFF would start a marker. + let mut open = scan(&[9; 10]); + assert_eq!(entropy_bytes_after(&open, 0), 10); + open.push(0xFF); + assert_eq!(entropy_bytes_after(&open, 0), 10); + assert_eq!(entropy_bytes_after(&[], 0), 0); +} + +#[test] +fn frames_past_the_pixel_limit_are_rejected() { + // 4097 x 4097 is just past 2^24 pixels; its 263,169 blocks are + // backed by enough scan data, so only the pixel limit stops it. + let mut tail = scan(&vec![0x11; 33_000]); + tail.extend_from_slice(&[0xFF, MARKER_EOI]); + for marker in [MARKER_SOF0, MARKER_SOF2] { + assert_eq!( + decode_jpeg(&frame_only(marker, 4097, 4097, &tail)).unwrap_err(), + RenderError::BadJpeg("frame larger than pixel limit") + ); + } + // 4096 x 4096 is at the limit and passes it. + assert_eq!( + decode_jpeg(&frame_only(MARKER_SOF0, 4096, 4096, &tail)).unwrap_err(), + RenderError::BadJpeg("missing DC Huffman table") + ); +} + #[test] fn a_second_frame_is_an_error() { // A stream holds one frame. Each SOF used to size a fresh frame diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs b/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs index e97b21a2..b990e1e0 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs @@ -188,6 +188,7 @@ fn progressive_sos_rejects_dc_scan_with_se_nonzero() { bytes.push(0); // Ss bytes.push(5); // Se bytes.push(0); + bytes.push(0x00); // one byte of scan data bytes.push(0xFF); bytes.push(MARKER_EOI); @@ -225,6 +226,7 @@ fn progressive_sos_rejects_ac_scan_with_ss_greater_than_se() { bytes.push(20); // Ss bytes.push(5); // Se (< Ss) bytes.push(0); + bytes.push(0x00); // one byte of scan data bytes.push(0xFF); bytes.push(MARKER_EOI); @@ -265,6 +267,7 @@ fn progressive_ac_refinement_scan_without_a_table_is_an_error() { bytes.push(1); // Ss bytes.push(63); // Se bytes.push(1 << 4); // Ah=1, Al=0 + bytes.push(0x00); // one byte of scan data bytes.push(0xFF); bytes.push(MARKER_EOI); From 4aeb2a7ba37a7d18c93e2f9222ff80a107813cdf Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 11:36:16 -0800 Subject: [PATCH 12/13] Bound VARC condition and delta work per glyph 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. --- src/tables/multi_var_store.rs | 44 +++++++- src/tables/varc.rs | 187 +++++++++++++++++++++++++--------- src/tables/varc/tests.rs | 111 +++++++++++++++++++- 3 files changed, 289 insertions(+), 53 deletions(-) diff --git a/src/tables/multi_var_store.rs b/src/tables/multi_var_store.rs index 87f10295..c517ab82 100644 --- a/src/tables/multi_var_store.rs +++ b/src/tables/multi_var_store.rs @@ -431,7 +431,47 @@ impl<'a> MultiVarStore<'a> { /// Regions whose scalar is zero are skipped. Does nothing when the /// indices are out of range or the set is too short for its tuples. pub(crate) fn add_deltas(&self, outer: u16, inner: u32, coords: &[f32], out: &mut [f32]) { - let Some(sub) = self.subtable(outer) else { + let Some(slot) = self.subtable_slot(outer) else { + return; + }; + let scalars = self.slot_scalars(slot, coords); + self.add_slot_deltas(slot, inner, &scalars, out); + } + + /// The parsed subtable `outer` resolves to. Outers whose offsets + /// alias share a slot. + pub(crate) fn subtable_slot(&self, outer: u16) -> Option { + self.subtable_slots.get(usize::from(outer)).copied() + } + + /// Region indexes of the subtable in `slot`: the length of its + /// scalar list, and how many tuples each of its delta sets holds. + pub(crate) fn slot_region_count(&self, slot: usize) -> usize { + self.subtables + .get(slot) + .map_or(0, |s| s.region_indexes.len()) + } + + /// The scalar of each region index of the subtable in `slot` at + /// `coords`, for [`Self::add_slot_deltas`]. Costs one step per + /// region index plus one evaluation per distinct region. + pub(crate) fn slot_scalars(&self, slot: usize, coords: &[f32]) -> Vec { + self.subtables + .get(slot) + .map_or_else(Vec::new, |s| self.scalars_for(&s.region_indexes, coords)) + } + + /// [`Self::add_deltas`] for the subtable in `slot`, with its region + /// scalars already worked out by [`Self::slot_scalars`]. Walks at + /// most `scalars.len() * out.len()` values. + pub(crate) fn add_slot_deltas( + &self, + slot: usize, + inner: u32, + scalars: &[f32], + out: &mut [f32], + ) { + let Some(sub) = self.subtables.get(slot) else { return; }; let Some(&raw) = sub.delta_sets.get(inner as usize) else { @@ -443,7 +483,7 @@ impl<'a> MultiVarStore<'a> { } let mut values = TupleFetcher::new(raw); let mut skip = 0usize; - for scalar in self.scalars_for(&sub.region_indexes, coords) { + for &scalar in scalars { if scalar == 0.0 { skip = skip.saturating_add(out.len()); continue; diff --git a/src/tables/varc.rs b/src/tables/varc.rs index 01b913d6..94800daa 100644 --- a/src/tables/varc.rs +++ b/src/tables/varc.rs @@ -49,6 +49,7 @@ //! This module only reads VARC. Subsetting lives in the //! `sigilbuzz-subset` crate. +use alloc::collections::BTreeMap; use alloc::vec::Vec; use crate::error::{Error, Result}; @@ -126,11 +127,20 @@ const NO_VARIATION: u32 = 0xFFFF_FFFF; /// ignored. const MAX_COMPONENT_AXES: usize = 4096; -/// Condition tables one `composite_with_font_coords` call evaluates at -/// most. And and Or tables can share children, so a small table can -/// name a huge tree; past the budget every condition fails. +/// Condition table visits one `composite_with_font_coords` call makes at +/// most. Each table's result is kept for the call, so a table whose +/// children share a subtree is evaluated once, not once per path; past +/// the budget every condition fails. const MAX_CONDITION_TABLES: u32 = 1 << 16; +/// Delta work one `composite_with_font_coords` call does at most: one +/// unit per region index whose scalar it works out (once per +/// MultiItemVariationData subtable and call) and one per delta value it +/// walks (region indexes times values, per variation it applies). A +/// component of a real font walks a few hundred values; past the budget +/// no more deltas apply and conditions that need them fail. +const MAX_COMPOSITE_WORK: u64 = 1 << 20; + /// HarfBuzz's `HB_MAX_NESTING_LEVEL`: its sanitizer drops a condition /// nested deeper, which then does not hold. const MAX_CONDITION_DEPTH: usize = 64; @@ -271,13 +281,24 @@ impl<'a> Varc<'a> { gid: u16, coords: &[f32], font_coords: &[f32], + ) -> Option { + let mut eval = Eval::new(coords); + self.composite_in(gid, font_coords, &mut eval) + } + + /// [`Varc::composite_with_font_coords`] with the evaluation state + /// held by the caller, so tests can read the work it did. + fn composite_in( + &self, + gid: u16, + font_coords: &[f32], + eval: &mut Eval<'_>, ) -> Option { let idx = self.coverage.index_of(gid)? as usize; let raw = *self.glyph_records.get(idx)?; let mut composite = VarcComposite::default(); let mut r = Reader::new(raw); let mut total_coords = 0usize; - let mut condition_budget = MAX_CONDITION_TABLES; while !r.is_empty() { // A VarComponent stops when bytes run out. Reaching the // end mid-record means the font is malformed; we skip the @@ -287,11 +308,11 @@ impl<'a> Varc<'a> { break; }; if let Some(index) = record.condition_index { - if !self.condition_holds(index, coords, &mut condition_budget) { + if !self.condition_holds(index, eval) { continue; } } - let Some(c) = self.resolve_component(&record, coords, font_coords) else { + let Some(c) = self.resolve_component(&record, font_coords, eval) else { continue; }; total_coords = total_coords.saturating_add(c.coords.len()); @@ -369,21 +390,22 @@ impl<'a> Varc<'a> { }) } - /// Evaluates `record` at `coords`, the coords of the glyph the - /// component belongs to, in a font set to `font_coords`. `None` when - /// the component draws nothing. + /// Evaluates `record` at the coords of the glyph the component + /// belongs to (`eval.coords`), in a font set to `font_coords`. + /// `None` when the component draws nothing. fn resolve_component( &self, record: &ComponentRecord<'_>, - coords: &[f32], font_coords: &[f32], + eval: &mut Eval<'_>, ) -> Option { let gid = u16::try_from(record.gid).ok()?; + let coords = eval.coords; // Axis values and their deltas, in F2DOT14 units. let mut axis_values: Vec = record.axis_values.iter().map(|&v| v as f32).collect(); if let Some(index) = record.axis_values_var_index { - self.add_deltas(index, coords, &mut axis_values); + self.add_deltas(index, &mut axis_values, eval); } // The child starts from the parent's coord vector, or with @@ -415,7 +437,7 @@ impl<'a> Varc<'a> { // then divided down: F4.12 for angles, F6.10 for scales. let mut values: Vec = record.fields.iter().map(|&(_, v)| f32::from(v)).collect(); if let Some(index) = record.transform_var_index { - self.add_deltas(index, coords, &mut values); + self.add_deltas(index, &mut values, eval); } let mut t = Decomposed::default(); for (&(field, _), &v) in record.fields.iter().zip(&values) { @@ -443,7 +465,8 @@ impl<'a> Varc<'a> { }) } - /// Whether condition `index` of the ConditionList holds at `coords`. + /// Whether condition `index` of the ConditionList holds at + /// `eval.coords`. /// /// ```text /// ConditionList: u32 count, Offset32 conditions[count] @@ -452,7 +475,7 @@ impl<'a> Varc<'a> { /// /// A missing list, an index past it, and a null or out-of-range /// offset all name HarfBuzz's Null condition, which does not hold. - fn condition_holds(&self, index: u32, coords: &[f32], budget: &mut u32) -> bool { + fn condition_holds(&self, index: u32, eval: &mut Eval<'_>) -> bool { let Some(list) = self.condition_list else { return false; }; @@ -468,14 +491,14 @@ impl<'a> Varc<'a> { let Some(offset) = slot.and_then(|at| be_u32(list, at)) else { return false; }; - match list.get(offset as usize..) { - Some(condition) if offset != 0 => self.evaluate_condition(condition, coords, 0, budget), + match usize::try_from(offset) { + Ok(at) if at != 0 => self.evaluate_condition(list, at, 0, eval), _ => false, } } - /// Evaluates the condition table at the start of `data`, as - /// HarfBuzz's `Condition::evaluate` does: + /// Evaluates the condition table at byte `at` of the ConditionList + /// `list`, as HarfBuzz's `Condition::evaluate` does: /// /// ```text /// 1 AxisRange: u16 format, u16 axisIndex, F2DOT14 min, F2DOT14 max @@ -487,27 +510,44 @@ impl<'a> Varc<'a> { /// /// Offsets are from the start of the condition that holds them. A /// null offset names the Null condition, which does not hold, so - /// its negation does. A table cut short, an unknown format, a table - /// nested deeper than `MAX_CONDITION_DEPTH` (HarfBuzz's sanitizer - /// drops those), and every table once `budget` runs out do not - /// hold either. + /// its negation does. A table cut short or past the list, an + /// unknown format, a table nested deeper than `MAX_CONDITION_DEPTH` + /// (HarfBuzz's sanitizer drops those), and every table once the + /// call's visit budget runs out do not hold either. + /// + /// A table's result depends only on the coords, so the call keeps + /// it: a later visit, through another path into a shared subtree, + /// reads it back instead of walking the subtree again. fn evaluate_condition( &self, - data: &[u8], - coords: &[f32], + list: &[u8], + at: usize, depth: usize, - budget: &mut u32, + eval: &mut Eval<'_>, ) -> bool { - if depth >= MAX_CONDITION_DEPTH || *budget == 0 { + if depth >= MAX_CONDITION_DEPTH || eval.condition_visits_left == 0 { return false; } - *budget -= 1; - let child = |offset: usize, budget: &mut u32| -> bool { - let Some(at) = be_u24(data, offset) else { - return false; - }; - match data.get(at..) { - Some(child) if at != 0 => self.evaluate_condition(child, coords, depth + 1, budget), + eval.condition_visits_left -= 1; + if let Some(&known) = eval.conditions.get(&at) { + return known; + } + let holds = self.condition_table(list, at, depth, eval); + eval.conditions.insert(at, holds); + holds + } + + /// The uncached body of [`Self::evaluate_condition`]. + fn condition_table(&self, list: &[u8], at: usize, depth: usize, eval: &mut Eval<'_>) -> bool { + let Some(data) = list.get(at..) else { + return false; + }; + // The child condition behind the Offset24 at `field`. + let child = |field: usize, eval: &mut Eval<'_>| -> bool { + match be_u24(data, field) { + Some(off) if off != 0 => { + self.evaluate_condition(list, at.saturating_add(off), depth + 1, eval) + } _ => false, } }; @@ -520,7 +560,8 @@ impl<'a> Varc<'a> { }; // HarfBuzz compares whole F2DOT14 values; an axis the // coords do not reach is at its default. - let coord = coords + let coord = eval + .coords .get(usize::from(axis)) .map_or(0.0, |&c| hb_roundf(c * 16384.0)); f32::from(min as i16) <= coord && coord <= f32::from(max as i16) @@ -530,8 +571,8 @@ impl<'a> Varc<'a> { return false; }; let mut value = [f32::from(default as i16)]; - self.add_deltas(index, coords, &mut value); - value[0] > 0.0 + // Out of delta budget the value is unknown, so it fails. + self.add_deltas(index, &mut value, eval) && value[0] > 0.0 } Some(format @ (3 | 4)) => { let Some(&count) = data.get(2) else { @@ -542,27 +583,79 @@ impl<'a> Varc<'a> { if data.len() < 3 + 3 * usize::from(count) { return false; } - let mut offsets = (0..usize::from(count)).map(|i| 3 + 3 * i); + let mut fields = (0..usize::from(count)).map(|i| 3 + 3 * i); if format == 3 { - offsets.all(|at| child(at, budget)) + fields.all(|field| child(field, eval)) } else { - offsets.any(|at| child(at, budget)) + fields.any(|field| child(field, eval)) } } - Some(5) => data.len() >= 5 && !child(2, budget), + Some(5) => data.len() >= 5 && !child(2, eval), _ => false, } } - /// Adds the deltas of the variation index `index` at `coords` to - /// `values`. Nothing is added at the default instance (empty + /// Adds the deltas of the variation index `index` at `eval.coords` + /// to `values`, region by region into the stored values as HarfBuzz + /// adds them. Nothing is added at the default instance (empty /// coords), for `NO_VARIATION`, or without a store, as in HarfBuzz. - fn add_deltas(&self, index: u32, coords: &[f32], values: &mut [f32]) { - if coords.is_empty() || index == NO_VARIATION { - return; + /// + /// The region scalars of each subtable are worked out once per call + /// and kept. Working them out and walking the delta set are charged + /// to the call's work budget; once that runs out this adds nothing + /// and returns false. + fn add_deltas(&self, index: u32, values: &mut [f32], eval: &mut Eval<'_>) -> bool { + if eval.coords.is_empty() || index == NO_VARIATION { + return true; } - if let Some(store) = &self.var_store { - store.add_deltas((index >> 16) as u16, index & 0xFFFF, coords, values); + let Some(store) = &self.var_store else { + return true; + }; + let Some(slot) = store.subtable_slot((index >> 16) as u16) else { + return true; + }; + let regions = store.slot_region_count(slot) as u64; + let mut cost = regions.saturating_mul(values.len() as u64); + if !eval.scalars.contains_key(&slot) { + cost = cost.saturating_add(regions); + } + if cost > eval.work_left { + eval.work_left = 0; + return false; + } + eval.work_left -= cost; + let coords = eval.coords; + let scalars = eval + .scalars + .entry(slot) + .or_insert_with(|| store.slot_scalars(slot, coords)); + store.add_slot_deltas(slot, index & 0xFFFF, scalars, values); + true + } +} + +/// The state of one `composite_with_font_coords` call: what depends only +/// on the glyph's coords, kept so it is worked out once, and the call's +/// budgets. +struct Eval<'c> { + /// The coords of the glyph the components belong to. + coords: &'c [f32], + /// Condition results by the table's offset in the ConditionList. + conditions: BTreeMap, + condition_visits_left: u32, + /// Region scalars by MultiItemVariationData subtable slot. + scalars: BTreeMap>, + work_left: u64, +} + +impl<'c> Eval<'c> { + fn new(coords: &'c [f32]) -> Self { + Self { + coords, + conditions: BTreeMap::new(), + condition_visits_left: MAX_CONDITION_TABLES, + scalars: BTreeMap::new(), + work_left: MAX_COMPOSITE_WORK, } } } diff --git a/src/tables/varc/tests.rs b/src/tables/varc/tests.rs index a51f6e14..d0bcd91f 100644 --- a/src/tables/varc/tests.rs +++ b/src/tables/varc/tests.rs @@ -736,10 +736,11 @@ fn conditions_nested_past_64_levels_fail() { } #[test] -fn shared_condition_trees_stop_at_the_budget() { +fn shared_condition_trees_are_evaluated_once() { // Five levels of And tables whose 255 offsets all name the next - // level: 255^5 visits without a budget. Every leaf holds, so only - // the budget can stop the walk, and the component is left out. + // level: 255^5 paths. Each table's result is kept for the call, so + // the walk visits each level's 255 offsets once and the component, + // whose every leaf holds, shows. let level_len = 3 + 3 * 255; let mut conditions = Vec::new(); for _ in 0..5 { @@ -754,7 +755,109 @@ fn shared_condition_trees_stop_at_the_budget() { &condition_list(&[conditions]), ); let varc = Varc::parse(&table).unwrap(); - assert!(varc.composite(1, &[0.0]).unwrap().components.is_empty()); + let mut eval = Eval::new(&[0.0]); + let c = varc.composite_in(1, &[0.0], &mut eval).unwrap(); + assert_eq!(c.components.len(), 1); + assert_eq!( + MAX_CONDITION_TABLES - eval.condition_visits_left, + 1 + 5 * 255 + ); +} + +/// A MultiItemVariationStore with one region (axis 0: 0, 1, 1) and one +/// subtable that names it `mentions` times, whose one delta set holds a +/// delta of 1 per mention. +fn mention_store(mentions: u16) -> Vec { + let mut store = Vec::new(); + store.extend_from_slice(&1u16.to_be_bytes()); // format + store.extend_from_slice(&12u32.to_be_bytes()); // region list + store.extend_from_slice(&1u16.to_be_bytes()); // one subtable + store.extend_from_slice(&28u32.to_be_bytes()); // its offset + store.extend_from_slice(&1u16.to_be_bytes()); // region count, at 12 + store.extend_from_slice(&6u32.to_be_bytes()); + store.extend_from_slice(&1u16.to_be_bytes()); // one axis + store.extend_from_slice(&0u16.to_be_bytes()); + store.extend_from_slice(&f2dot14(0.0)); + store.extend_from_slice(&f2dot14(1.0)); + store.extend_from_slice(&f2dot14(1.0)); + assert_eq!(store.len(), 28); + store.push(1); // subtable format + store.extend_from_slice(&mentions.to_be_bytes()); + for _ in 0..mentions { + store.extend_from_slice(&0u16.to_be_bytes()); + } + // Runs of up to 64 i8 deltas of 1. + let mut deltas = Vec::new(); + let mut left = usize::from(mentions); + while left > 0 { + let run = left.min(64); + deltas.push((run - 1) as u8); + deltas.extend(core::iter::repeat(1u8).take(run)); + left -= run; + } + store.extend_from_slice(&build_cff2_index(&[&deltas])); + store +} + +/// A VARC table with one component of glyph 10 gated by a chain of +/// `levels` And tables, each with two offsets that both name the next, +/// ending in the Value condition `default + delta > 0` over +/// [`mention_store`]. +fn deep_value_condition_table(levels: usize, mentions: u16, default: i16) -> Vec { + let mut condition = vec![0, 2]; + condition.extend_from_slice(&default.to_be_bytes()); + condition.extend_from_slice(&0u32.to_be_bytes()); + for _ in 0..levels { + // And with two offsets, both to the child right after them. + let mut and = vec![0, 3, 2]; + and.extend_from_slice(&9u32.to_be_bytes()[1..]); + and.extend_from_slice(&9u32.to_be_bytes()[1..]); + and.extend_from_slice(&condition); + condition = and; + } + with_conditions( + build_varc(&[1], &[&gated(10, 0)], Some(&mention_store(mentions)), None), + &condition_list(&[condition]), + ) +} + +#[test] +fn deep_shared_value_conditions_cost_linear_work() { + // 20 And levels whose two offsets name the same child (2^20 paths) + // over a Value condition whose delta set walks 60,000 mentions of + // one region. Each table is evaluated once and the subtable's + // scalars are worked out once: 41 table visits, 60,000 scalar steps, + // and 60,000 delta values. Walking every path took 2^20 times that. + let table = deep_value_condition_table(20, 60_000, -30_000); + let varc = Varc::parse(&table).unwrap(); + // At axis 0 = 1 the deltas add 60,000: the condition holds. + let mut eval = Eval::new(&[1.0]); + let c = varc.composite_in(1, &[1.0], &mut eval).unwrap(); + assert_eq!(c.components.len(), 1); + assert_eq!(MAX_CONDITION_TABLES - eval.condition_visits_left, 41); + assert_eq!(MAX_COMPOSITE_WORK - eval.work_left, 120_000); + // At 0.25 they add 15,000: it does not. + assert!(varc.composite(1, &[0.25]).unwrap().components.is_empty()); +} + +#[test] +fn delta_work_past_the_budget_adds_nothing() { + // Twenty components whose translation varies through the delta set + // of 60,000 region mentions. The first costs 120,000 units (the + // scalars, then the walk), each later one 60,000, so 16 fit the + // 2^20 budget and the rest keep their stored translation. + let flags = (VC_TRANSFORM_HAS_VARIATION | VC_HAVE_TRANSLATE_X) as u8; + let mut record = Vec::new(); + for _ in 0..20 { + record.extend_from_slice(&[flags, 0x00, 0x05, 0x00]); + record.extend_from_slice(&0i16.to_be_bytes()); + } + let table = build_varc(&[1], &[&record], Some(&mention_store(60_000)), None); + let varc = Varc::parse(&table).unwrap(); + let c = varc.composite(1, &[1.0]).unwrap(); + let tx: Vec = c.components.iter().map(|c| c.transform[4]).collect(); + assert_eq!(tx[..16], [60_000.0; 16]); + assert_eq!(tx[16..], [0.0; 4]); } #[test] From 4dd52b339630d9f573c777ccbe7b9e4601f534b7 Mon Sep 17 00:00:00 2001 From: albedosehen Date: Sun, 4 Oct 2026 11:50:56 -0800 Subject: [PATCH 13/13] Cap JPEG frames at 2^22 pixels 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. --- crates/sigilbuzz-render/src/jpeg_decode.rs | 23 ++++++++++--------- .../src/jpeg_decode/tests/adversarial.rs | 14 +++++------ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/crates/sigilbuzz-render/src/jpeg_decode.rs b/crates/sigilbuzz-render/src/jpeg_decode.rs index cd2bd034..4e2c80bb 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode.rs @@ -26,8 +26,8 @@ //! blocks than eight per byte of entropy-coded data in the scans //! after it (COM, APPn and other segments do not count) is rejected //! before any sample buffer is allocated. -//! - **At most 2^24 pixels** (4096 x 4096) per frame, which keeps one -//! decode near 220 MB; see `MAX_JPEG_PIXELS`. +//! - **At most 2^22 pixels** (2048 x 2048) per frame, which keeps one +//! decode near 55 MB; see `MAX_JPEG_PIXELS`. //! - **Bounded scan work.** The scans of the stream's frame together //! may visit at most 1024 coefficients per 8x8 block, which a valid //! scan script never needs (each coefficient coded once and refined @@ -96,15 +96,16 @@ const MARKER_COM: u8 = 0xFE; /// blow up `usize` math via a 65535x65535 SOF0. const MAX_JPEG_DIM: u32 = 16384; -/// Most pixels a frame may hold: 2^24, 4096 x 4096. JPEG data in a +/// Most pixels a frame may hold: 2^22, 2048 x 2048. JPEG data in a /// font is an sbix glyph image, drawn for one strike's ppem, and a -/// strike that large would be sixteen times the area of a 1024 ppem -/// one. 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 the cap keeps one decode near -/// 220 MB. Without it a stream with enough entropy-coded data could ask -/// for 16384 x 16384, about 3.5 GB. -const MAX_JPEG_PIXELS: u64 = 1 << 24; +/// strike that large 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 the cap +/// keeps one decode near 55 MB and its time well under a second. Without +/// it a stream with enough entropy-coded data could ask for 16384 x +/// 16384, about 3.5 GB. +const MAX_JPEG_PIXELS: u64 = 1 << 22; /// Hard cap on Huffman table count. JPEG allows 4 of each AC/DC class, /// so the worst legitimate case is 8 tables. @@ -138,7 +139,7 @@ const SCAN_WORK_PER_BLOCK: u64 = 1024; /// spectral selection and successive approximation, refinement scans /// included) modes are handled. Arithmetic coding, 16-bit precision, /// hierarchical mode, restart markers, a second frame, a frame past -/// 2^24 pixels or larger than its scan data can back, and JPEG2000 / +/// 2^22 pixels or larger than its scan data can back, and JPEG2000 / /// TIFF return [`RenderError::BadJpeg`]. /// /// # Errors diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs b/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs index 82201606..d268dfc5 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs @@ -355,8 +355,8 @@ fn comment(len: usize) -> Vec { fn only_scan_data_backs_a_frame() { // A 2048x2048 grayscale frame has 65,536 blocks, so its scans need // at least 8,192 bytes. COM padding after the frame header used to - // count, so a few KB of comments let the header size about 300 MB - // of buffers. + // count, so a few KB of comments let a header size buffers that no + // scan data backs (a 574 KB stream of them reached 1.8 GB). let mut tail = comment(9000); tail.extend(scan(&[0])); tail.extend_from_slice(&[0xFF, MARKER_EOI]); @@ -399,19 +399,19 @@ fn entropy_bytes_count_only_scan_data() { #[test] fn frames_past_the_pixel_limit_are_rejected() { - // 4097 x 4097 is just past 2^24 pixels; its 263,169 blocks are + // 2049 x 2049 is just past 2^22 pixels; its 66,049 blocks are // backed by enough scan data, so only the pixel limit stops it. - let mut tail = scan(&vec![0x11; 33_000]); + let mut tail = scan(&vec![0x11; 8300]); tail.extend_from_slice(&[0xFF, MARKER_EOI]); for marker in [MARKER_SOF0, MARKER_SOF2] { assert_eq!( - decode_jpeg(&frame_only(marker, 4097, 4097, &tail)).unwrap_err(), + decode_jpeg(&frame_only(marker, 2049, 2049, &tail)).unwrap_err(), RenderError::BadJpeg("frame larger than pixel limit") ); } - // 4096 x 4096 is at the limit and passes it. + // 2048 x 2048 is at the limit and passes it. assert_eq!( - decode_jpeg(&frame_only(MARKER_SOF0, 4096, 4096, &tail)).unwrap_err(), + decode_jpeg(&frame_only(MARKER_SOF0, 2048, 2048, &tail)).unwrap_err(), RenderError::BadJpeg("missing DC Huffman table") ); }