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-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/crates/sigilbuzz-render/src/jpeg_decode.rs b/crates/sigilbuzz-render/src/jpeg_decode.rs index beb3b2c0..4e2c80bb 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode.rs @@ -7,11 +7,12 @@ //! //! - **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). 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`. @@ -22,8 +23,16 @@ //! [`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^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 +//! 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). @@ -45,9 +54,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. @@ -90,6 +96,17 @@ 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^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 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. const MAX_HUFF_TABLES: usize = 4; @@ -97,6 +114,18 @@ const MAX_HUFF_TABLES: usize = 4; /// Hard cap on quantization table count. Same logic: 4 destinations. const MAX_QT_TABLES: usize = 4; +/// 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; since a +/// stream holds one frame, it bounds the whole stream at 1024 visits +/// 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; + // --------------------------------------------------------------------------- // Public API. // --------------------------------------------------------------------------- @@ -107,11 +136,11 @@ 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, -/// hierarchical mode, restart markers, and JPEG2000 / TIFF return -/// [`RenderError::BadJpeg`]. +/// 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^22 pixels or larger than its scan data can back, and JPEG2000 / +/// TIFF return [`RenderError::BadJpeg`]. /// /// # Errors /// Returns [`RenderError::BadJpeg`] on any structural problem, @@ -159,6 +188,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 +207,7 @@ impl<'a> Decoder<'a> { progressive: false, coeffs: Vec::new(), blocks_per_comp: Vec::new(), + scan_budget: 0, } } @@ -361,6 +394,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")); @@ -414,10 +454,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 @@ -429,10 +470,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(); @@ -476,6 +520,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); } } @@ -483,6 +533,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/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..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 }; @@ -141,10 +143,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 +218,51 @@ 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)) + } + + /// 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( @@ -226,7 +274,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 +310,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 +325,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 +365,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); @@ -324,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 { @@ -399,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.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/adversarial.rs b/crates/sigilbuzz-render/src/jpeg_decode/tests/adversarial.rs index 61ed4ec1..d268dfc5 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")), @@ -333,3 +335,115 @@ fn idct_cos_table_matches_inline_cosines() { assert_eq!(a, b); } } + +/// 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 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]); + 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() { + // 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; 8300]); + tail.extend_from_slice(&[0xFF, MARKER_EOI]); + for marker in [MARKER_SOF0, MARKER_SOF2] { + assert_eq!( + decode_jpeg(&frame_only(marker, 2049, 2049, &tail)).unwrap_err(), + RenderError::BadJpeg("frame larger than pixel limit") + ); + } + // 2048 x 2048 is at the limit and passes it. + assert_eq!( + decode_jpeg(&frame_only(MARKER_SOF0, 2048, 2048, &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 + // 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") + ); +} 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..77a9faaf --- /dev/null +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests/encode.rs @@ -0,0 +1,540 @@ +//! 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 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, data) in scans { + sos(&mut out, &s.comps, s.ss, s.se, s.ah, s.al); + 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 { + // 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 +} + +/// 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 00000000..9988518f Binary files /dev/null and b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_base.jpg differ diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_prog.jpg b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_prog.jpg new file mode 100644 index 00000000..a9cb912e Binary files /dev/null and b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_prog.jpg differ diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_prog.raw b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_prog.raw new file mode 100644 index 00000000..b2e268c6 --- /dev/null +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_gray_47x9_prog.raw @@ -0,0 +1 @@ +›ž £¤§©¬®°³´¶¸º¼½½¾¾¾½¼¼¸º¶¶¯°««¨¦¤¡ �š——•’��‹‰œž¡£¥§ª¬®°³µ¶¸»½ÀÀÁÂÁÀ¾½»¸µ²³±®ª©§¤¢ �š˜—•’��‹‰œž¡¤¥§ª­¯±´¶¸º½¿ÂÃÄÅÄÂÀ¿¼¸¹´¸±°«©§¤¢ ž›˜—•’��‹‰�Ÿ¢¤¦¨«­¯±´·¹»¾ÀÃÄÆÇÆÃÀ¾¼º¸µ±®­®ª¨¥£¡ž›™—•’��‹‰�Ÿ¢¤¦¨«­¯²µ·¹»¾ÀÃÄÆÇÆÄÁ¿¼¼³»²¶­¯ª¨¥£¡ž›™—•’��‹‰�Ÿ¢¤¦¨«­¯²µ·¸»½ÀÃÄÆÇÇÄÁ¿»½·ÎÇ˱ªª¨¥£¡ž›™—•’��‹‰œŸ¡¤¥¨ª­¯±´¶¸º¼¾ÁÂÄÅÅÃÀ¿º¼ºÎÊɱ©©§¥¢ ž›˜—•’��‹‰œž¡£¥§ª¬¯±´¶·¹¼¾¾ÀÁÃÂÁ¿½¼¸²·±°«®©§¤¢ žš˜—•’��‹‰›ž¡£¥§©«®°²µ·¸º¼½¾¿À¿¾¼»¹¸¶´²¯¬ª¨¦¤¢Ÿ�š˜–”’��Љ \ No newline at end of file diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb420_33x17_base.jpg b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb420_33x17_base.jpg new file mode 100644 index 00000000..466b28d8 Binary files /dev/null and b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb420_33x17_base.jpg differ diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb420_33x17_prog.jpg b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb420_33x17_prog.jpg new file mode 100644 index 00000000..dd8d0227 Binary files /dev/null and b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb420_33x17_prog.jpg differ 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 00000000..a48295b1 Binary files /dev/null and b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb422_33x17_base.jpg differ 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 00000000..c4b0071b Binary files /dev/null and b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb422_33x17_prog.jpg differ diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_base.jpg b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_base.jpg new file mode 100644 index 00000000..9a568986 Binary files /dev/null and b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_base.jpg differ 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 00000000..3e955cf0 Binary files /dev/null and b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_prog.jpg differ diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_prog.raw b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_prog.raw new file mode 100644 index 00000000..fe2f7d07 --- /dev/null +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests/fixtures/pillow_rgb444_20x12_prog.raw @@ -0,0 +1 @@ +ݻ߽â¾ãÀæÀçÁêÃêÃëÂéÂ%äÀ,çÊ åÀç¿à·)ݽݺÛ¸Ù¶׳޼߽â¾äÁçÁéÄëÄìÅïÌëÊæÈæÀ/æÁèÁ(äÃâÁݺܹÙ¶׳Þ¼á½ãÀçÁéÄìÅíÇïÉéÊëÈ&ìÅ(éÄêÄèÂ!ã¾'Þ»#Þ»ܹÚ·Ø´à¼â¿äÁéÄëÆîÈðÊóËíÍ"ñÍ!õÐñÅ$ðÈêÃ*èÄã¾ß¼ ݺÛ¸ Ùµá¾â¿çÁêÅíÇïÉóËõÍõÓøÍ(õÈ$ïÂ'èÆåÄèÂë¾%à½!Þ»Û¸Ù¶á½ãÀçÁêÅíÇðÊôÌõÍöÏ÷Ë$óÇôÐ"çÎæÊ)â½&é¹'à¾Þ»Û¸Ú·á½å¿ èÂëÄíÇòÊôÌõÍøÏ)øÐ òÐÿÄòðÂçîÀåáÄ*çÂà¾Þ»Û¸Ú·ã¼å¿ çÁ ëÄíÇñÈóËõÍ÷Ò"ôÐçÌ úÇÿêÇïìÆõÚÄ2ã½à¾Þ»Û¸Ú·à½ã½æÀéÄëÆîÈïÉðÊõÈ'ïÌêÒ÷¾ÿõÂíñ»ßâÅ+âÀ!ß¼ ݺÚ·×¶ß»â¼å¿çÁéÄëÆíÇîÈõÉíÈãÇèÏâËãÈß¾ä¾Þ»ܹÚ·×µÞºß»ä¾æÀçÁéÄêÅìÆñÈîÆ(éÀ4éÆ$ã¿!ãÀâ¼æÀݺÛ¸Ù¶Ö´ݹß»á½ã¿ æÀçÁèÃéÄäÀ$éÅêÇ ìÀ+è½!æ¼*â½à¹ݺÛ¸صÕ³ \ No newline at end of file diff --git a/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs b/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs index 0623f677..b990e1e0 100644 --- a/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs +++ b/crates/sigilbuzz-render/src/jpeg_decode/tests/progressive.rs @@ -1,7 +1,12 @@ //! Progressive (SOF2) decode tests built from synthetic two-scan //! streams. +use super::encode::{ + assemble_progressive, deep_script, encode_baseline, encode_progressive, libjpeg_script, + raw_data, scan, scan_data, spectral_script, Image, Scan, +}; use super::*; +use alloc::format; /// Build a minimal **progressive** (SOF2) grayscale JPEG that /// encodes a single 8x8 block via two scans: @@ -183,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); @@ -220,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); @@ -231,10 +238,9 @@ fn progressive_sos_rejects_ac_scan_with_ss_greater_than_se() { } #[test] -fn progressive_ac_refinement_scan_is_unsupported() { - // SOF2 + an AC scan with Ah=1 (refinement). The implementation - // surfaces this as BadJpeg explicitly because AC refinement - // is not implemented. +fn progressive_ac_refinement_scan_without_a_table_is_an_error() { + // SOF2 + an AC scan with Ah=1 (refinement) and no DHT. A + // refinement scan codes its run/size symbols with the AC table. let mut bytes = vec![0xFF, MARKER_SOI]; bytes.push(0xFF); bytes.push(MARKER_DQT); @@ -261,12 +267,350 @@ fn progressive_ac_refinement_scan_is_unsupported() { 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); let err = decode_jpeg(&bytes).unwrap_err(); assert!(matches!( err, - RenderError::BadJpeg("progressive AC refinement scans not supported") + RenderError::BadJpeg("missing AC Huffman table") )); } + +/// 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] + ); + } +} + +#[test] +fn progressive_refinement_scripts_match_baseline() { + // libjpeg's default progression (what Pillow writes) and a script + // that refines every bit in its own pass rebuild the coefficients + // exactly, so they decode to the baseline pixels. + for (li, layout) in LAYOUTS.iter().enumerate() { + for (si, &(w, h)) in SIZES.iter().enumerate() { + let seed = 0x85EB_CA6B ^ ((li as u32) << 8) ^ si as u32; + let img = Image::random(w, h, layout, seed, 100, 3); + let baseline = decode_jpeg(&encode_baseline(&img)).unwrap(); + for (name, script) in [ + ("libjpeg", libjpeg_script(layout.len())), + ("deep", deep_script(layout.len())), + ] { + let progressive = decode_jpeg(&encode_progressive(&img, &script)).unwrap(); + let what = format!("{name} script, {layout:?} at {w}x{h}"); + assert_same_pixels(&progressive, &baseline, &what); + } + } + } +} + +#[test] +fn progressive_refinement_eob_runs_match_baseline() { + // Sparse blocks: refinement scans code long EOB runs that carry + // the correction bits of every block they end, and ZRLs that the + // encoder can only fold into an EOB past the last new coefficient. + for layout in [LAYOUTS[0], LAYOUTS[3]] { + for amplitude in [1, 3, 70] { + let img = Image::random(96, 64, layout, 0xC2B2_AE35, amplitude, 30); + let baseline = decode_jpeg(&encode_baseline(&img)).unwrap(); + for script in [libjpeg_script(layout.len()), deep_script(layout.len())] { + let progressive = decode_jpeg(&encode_progressive(&img, &script)).unwrap(); + let what = format!("{layout:?}, amplitude {amplitude}"); + assert_same_pixels(&progressive, &baseline, &what); + } + } + } +} + +/// An 8x16 grayscale frame (two blocks) whose AC coefficients are all +/// zero, so a refinement scan over it has no correction bits to read, +/// and the scans that code it at `Al = 1`. +fn flat_two_blocks() -> (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/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.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/contextual.rs b/src/tables/morx/contextual.rs index a349f5a1..2fc9db7d 100644 --- a/src/tables/morx/contextual.rs +++ b/src/tables/morx/contextual.rs @@ -2,10 +2,17 @@ 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 --- +/// 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,55 +77,28 @@ 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 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) - } + let at = usize::from(idx) * 4; + let off = u32::from_be_bytes(*substitutions.get(at..)?.first_chunk::<4>()?) as usize; + 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/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/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 9a712b7d..c8c5fc16 100644 --- a/src/tables/morx/tests.rs +++ b/src/tables/morx/tests.rs @@ -659,3 +659,270 @@ 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]); +} + +// ----------------------------------------------------------------- +// 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])); +} + +#[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]); +} diff --git a/src/tables/multi_var_store.rs b/src/tables/multi_var_store.rs index 4a068964..c517ab82 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,182 @@ 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(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 { + 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 scalars { + 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 +609,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 +648,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 +701,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 +828,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 +851,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 b0ddd45f..94800daa 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 //! ``` @@ -37,32 +39,34 @@ //! # 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 //! //! 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::collections::BTreeMap; 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::multi_var_store::{decode_tuple_values, read_cff2_index, MultiVarStore}; +use crate::tables::parse::{hb_roundf, Reader}; /// A parsed `VARC` table. #[derive(Debug, Clone)] 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>, + 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). @@ -80,9 +84,13 @@ 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. + /// 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, } @@ -93,9 +101,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; @@ -112,6 +119,32 @@ 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; + +/// 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; + /// 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 @@ -133,7 +166,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; @@ -155,17 +188,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 { @@ -173,11 +197,19 @@ 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 }; + // 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 { @@ -188,6 +220,7 @@ impl<'a> Varc<'a> { Ok(Self { coverage, var_store, + condition_list, axis_indices_lists, glyph_records, }) @@ -210,11 +243,57 @@ 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. + /// `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 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(); @@ -225,9 +304,17 @@ 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; }; + if let Some(index) = record.condition_index { + if !self.condition_holds(index, eval) { + continue; + } + } + let Some(c) = self.resolve_component(&record, font_coords, eval) else { + continue; + }; total_coords = total_coords.saturating_add(c.coords.len()); if total_coords > MAX_COMPOSITE_COORDS { break; @@ -237,180 +324,423 @@ 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 { + u32::from(r.read_u16()?) + }; + let condition_index = if flags & VC_HAVE_CONDITION != 0 { + Some(read_uint32var(r)?) + } else { + None + }; + 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 { - r.read_u16()? + (&[][..], 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()?)); + } + } + for _ in 0..(flags & VC_RESERVED_MASK).count_ones() { + read_uint32var(r)?; + } + Ok(ComponentRecord { + flags, + gid, + condition_index, + axis_indices, + axis_values, + axis_values_var_index, + transform_var_index, + fields, + }) + } - // ConditionList: read and discard. sigilbuzz does not gate - // components on conditions yet. - if flags & VC_HAVE_CONDITION != 0 { - let _ = read_uint32var(r)?; + /// 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<'_>, + 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, &mut axis_values, eval); } - // 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` F2DOT14 values, but - // sigilbuzz's decode_tuple_values yields i32; we treat - // each as F2DOT14 by dividing by 16384. - 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(); - - // Optional per-value variation deltas. - if flags & VC_AXIS_VALUES_HAVE_VARIATION != 0 { - let var_idx = read_uint32var(r)?; - if let Some(store) = &self.var_store { - 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; - } - } - } + // 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 { + continue; } - - // 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); - } + if child_coords.len() <= axis { + child_coords.resize(axis + 1, 0.0); } - for (axis_index, value) in axis_indices.iter().zip(axis_values.iter()) { - effective_coords[*axis_index as usize] = *value; + child_coords[axis] = hb_roundf(value) / 16384.0; + } + + // 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, &mut values, eval); + } + 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, } } + // ScaleY defaults to ScaleX, not to 1. + if record.flags & VC_HAVE_SCALE_Y == 0 { + t.sy = t.sx; + } - // 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 { - Some(read_uint32var(r)?) - } else { - None - }; + Some(VarcComponent { + gid, + transform: t.to_affine(), + coords: child_coords, + }) + } - // 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); - } - if flags & VC_HAVE_TRANSLATE_Y != 0 { - ty = f32::from(r.read_i16()?); - present_fields.push(TransformField::TranslateY); + /// Whether condition `index` of the ConditionList holds at + /// `eval.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, eval: &mut Eval<'_>) -> 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; } - if flags & VC_HAVE_ROTATION != 0 { - rotation = read_f4dot12(r)?; - present_fields.push(TransformField::Rotation); + 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 usize::try_from(offset) { + Ok(at) if at != 0 => self.evaluate_condition(list, at, 0, eval), + _ => false, } - if flags & VC_HAVE_SCALE_X != 0 { - sx = read_f6dot10(r)?; - present_fields.push(TransformField::ScaleX); + } + + /// 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 + /// 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 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, + list: &[u8], + at: usize, + depth: usize, + eval: &mut Eval<'_>, + ) -> bool { + if depth >= MAX_CONDITION_DEPTH || eval.condition_visits_left == 0 { + return false; } - if flags & VC_HAVE_SCALE_Y != 0 { - sy = read_f6dot10(r)?; - present_fields.push(TransformField::ScaleY); + eval.condition_visits_left -= 1; + if let Some(&known) = eval.conditions.get(&at) { + return known; } - if flags & VC_HAVE_SKEW_X != 0 { - skew_x = read_f4dot12(r)?; - present_fields.push(TransformField::SkewX); + 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, + } + }; + 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 = 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) + } + 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)]; + // 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 { + 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 fields = (0..usize::from(count)).map(|i| 3 + 3 * i); + if format == 3 { + fields.all(|field| child(field, eval)) + } else { + fields.any(|field| child(field, eval)) + } + } + Some(5) => data.len() >= 5 && !child(2, eval), + _ => false, } - if flags & VC_HAVE_SKEW_Y != 0 { - skew_y = read_f4dot12(r)?; - present_fields.push(TransformField::SkewY); + } + + /// 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. + /// + /// 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 flags & VC_HAVE_TCENTER_X != 0 { - tcx = f32::from(r.read_i16()?); - present_fields.push(TransformField::TCenterX); + 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 flags & VC_HAVE_TCENTER_Y != 0 { - tcy = f32::from(r.read_i16()?); - present_fields.push(TransformField::TCenterY); + 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 + } +} - // 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, - } - } - } - } +/// 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, } + } +} - // 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); +/// 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, + 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)>, +} - Ok(VarcComponent { - gid, - transform, - coords: effective_coords, - }) +/// 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, +} + +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)] @@ -426,6 +756,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 { @@ -460,141 +806,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 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) - } + let Some(&control) = self.data.get(self.p) else { + self.value = 0; + return false; }; - out.push(delta); + 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; + } + 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, + }; + 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 77970c40..d0bcd91f 100644 --- a/src/tables/varc/tests.rs +++ b/src/tables/varc/tests.rs @@ -329,21 +329,578 @@ 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 +/// 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]); +} + +/// 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()); +} + +/// `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_are_evaluated_once() { + // Five levels of And tables whose 255 offsets all name the next + // 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 { + 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(); + 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] +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); } 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 00000000..b4c2a46f Binary files /dev/null and b/tests/fixtures/aat_morx_contextual.ttf differ diff --git a/tests/fixtures/aat_morx_ligature.ttf b/tests/fixtures/aat_morx_ligature.ttf new file mode 100644 index 00000000..2b68740f Binary files /dev/null and b/tests/fixtures/aat_morx_ligature.ttf differ 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 00000000..9d848328 Binary files /dev/null and b/tests/fixtures/varc_parity.ttf differ 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() 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); +}