diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e83c2da..8141dfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI env: - FULLBLEED_CI_VERSION: "2.5.6" + FULLBLEED_CI_VERSION: "2.5.7" RUST_MIN_STACK: "4194304" on: @@ -157,6 +157,19 @@ jobs: path: target/text-extraction if-no-files-found: warn + - name: Verify compact font subsets with independent font and PDF readers + run: | + python -m pip install pypdf==6.19.0 pypdfium2==5.14.0 fonttools==4.65.0 pillow==12.3.0 + python -I tools/smoke_font_subsets.py --out target/font-subsets + + - name: Retain compact font subset PDFs and preview evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: font-subsets-${{ runner.os }} + path: target/font-subsets + if-no-files-found: warn + - name: Retain gradient PDFs and preview evidence if: always() uses: actions/upload-artifact@v7 diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 0177932..ee1c260 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -10,7 +10,7 @@ on: description: "Annotated release tag whose exact source commit will be published" required: true type: string - default: "v2.5.6" + default: "v2.5.7" publish: description: "Publish after all validation gates pass" required: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c2796c..ae05a57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -182,6 +182,19 @@ jobs: crates/fullbleed_audit_contract/target/package/*.crate if-no-files-found: error + - name: Verify compact font subsets with independent font and PDF readers + run: | + python -m pip install pypdf==6.19.0 pypdfium2==5.14.0 fonttools==4.65.0 pillow==12.3.0 + python -I tools/smoke_font_subsets.py --out target/font-subsets + + - name: Retain compact font subset PDFs and preview evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-font-subsets + path: target/font-subsets + if-no-files-found: error + build-wheels: name: Build wheel (${{ matrix.label }}) needs: validate diff --git a/Cargo.lock b/Cargo.lock index e7b4d20..056951e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "fullbleed" -version = "2.5.6" +version = "2.5.7" dependencies = [ "fullbleed_audit_contract", "subsetter", diff --git a/Cargo.toml b/Cargo.toml index bb6c372..a2bb9f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fullbleed" -version = "2.5.6" +version = "2.5.7" edition = "2024" rust-version = "1.85" license = "MIT" diff --git a/README.md b/README.md index b93a369..201e5ed 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ python -m pip install fullbleed From a local wheel: ```bash -python -m pip install C:\path\to\fullbleed-2.5.6-cp310-abi3-win_amd64.whl +python -m pip install C:\path\to\fullbleed-2.5.7-cp310-abi3-win_amd64.whl ``` From a source checkout with Rust installed, no Python build package is needed: diff --git a/ReleaseNotes.MD b/ReleaseNotes.MD index c9fee47..2aac64c 100644 --- a/ReleaseNotes.MD +++ b/ReleaseNotes.MD @@ -1,3 +1,23 @@ +# Fullbleed Release Notes - 2.5.7 + +Release date: October 5, 2026 + +## Smaller embedded TrueType subsets + +TrueType subsets now omit unused character-map entries, glyph names, and metrics +while preserving the original glyph IDs, retained outlines and instructions, +composite dependencies, advances, and font notices. Horizontal-header extrema +are recalculated for the subset. Character-map formats other than 4 and 12 keep +their original bytes; an unsupported or larger map rewrite uses the source map. + +The change adds no runtime dependency and does not change the rendering API. +PDF bytes and hashes change. Review stored PDF baselines before updating them. +Independent font, text, and pixel checks are described in the +[2.5.7 release runbook](docs/release/2.5.7-runbook.md). Claims remain limited to the +retained fixtures and validator reports. + +--- + # Fullbleed Release Notes - 2.5.6 Release date: October 3, 2026 diff --git a/cli_schema.md b/cli_schema.md index d0ce79f..3058c34 100644 --- a/cli_schema.md +++ b/cli_schema.md @@ -2,7 +2,7 @@ # CLI and Agent JSON Contract -Generated from the installed Fullbleed **2.5.6** runtime. The canonical machine artifact is `fullbleed-agent-contract.json`. +Generated from the installed Fullbleed **2.5.7** runtime. The canonical machine artifact is `fullbleed-agent-contract.json`. ## Authority and invocation diff --git a/docs/release/2.5.7-runbook.md b/docs/release/2.5.7-runbook.md new file mode 100644 index 0000000..fb45f7d --- /dev/null +++ b/docs/release/2.5.7-runbook.md @@ -0,0 +1,63 @@ +# Fullbleed 2.5.7 release runbook + +This patch compacts embedded TrueType metadata without renumbering retained +glyphs. Publish from a clean annotated `v2.5.7` tag after the checks below pass. + +## Build and verify + +Keep Cargo, Python, CI smoke metadata, release defaults, and generated agent +artifacts synchronized at 2.5.7. The optional MCP adapter stays at 0.1.1 and the +audit crate stays at 0.1.3. Historical example pins and measured comparisons keep +the versions they actually verified. + +Build and install a wheel in an isolated environment. Generate agent artifacts +using `tools/generate_agent_contract.py --python PATH --json`, rebuild and install +the final wheel, then run the generator with `--check`. Build the sdist from that +same source. Do not hand-edit generated agent artifacts. + +- Run the focused font-subset Rust tests, then the full Rust suite with default + and Python/SVG features and the packaged-crate MSRV check. +- Run focused installed-native font, text, preview, compiled-table and profile + Python tests, followed by the full suite with `FULLBLEED_TEST_INSTALLED_NATIVE=1`. +- Install test-only `pypdf==6.19.0`, `pypdfium2==5.14.0`, `fonttools==4.65.0`, and + `pillow==12.3.0`. Run `python -I tools/smoke_font_subsets.py --out TARGET`. + Retain all PDFs, source HTML/CSS, independent and native previews, table sizes, + versions and hashes. The check independently closes composite dependencies + and verifies glyph IDs, raw outlines/instructions, metrics, cmap mappings, + font notices, SFNT checksums and recalculated horizontal-header extrema. +- Record a public 2.5.6 baseline with `--allow-legacy-metadata`. Compare the + candidate using `--baseline BASELINE_DIRECTORY`. Identical inputs must retain + text, page count, PDFium pixels and native previews while reducing PDF bytes. + Running the normal check on 2.5.6 must reject its untrimmed metadata. +- Run the existing styled-text, gradient, template, compiled-binding and + reproducibility checks. Compare the three golden PDFs with public 2.5.6 using + an independent renderer before accepting changed PDF hashes; direct PNG + baselines must stay unchanged. +- Inspect representative invoice, ledger, multilingual, math, symbol and + non-BMP previews. The unshaped-text case exercises the exposed option too. +- Require Linux/Windows CI and print-profile gates. Retain raw external + validator reports. The optional dedicated PDF/VT validator remains + unconfigured; its skipped job is not independent conformance evidence. +- Run installed-package smoke, agent-contract checks, release metadata/license + checks, deterministic goldens, CSS status and bundled Skill validation. + +## Publish and verify + +Use the configured PyPI trusted publisher and crates.io workflow after the +final release gates pass. Retain the source tag, workflow conclusions and +artifact hashes. Install the public wheel in a fresh environment and repeat +the font-subset check before promoting the release. Optional bindings remain +independently versioned and require their own installed-package checks when +updating their pinned engine. + +The earlier three-renderer comparison stays an immutable 2.5.6 measurement. +Size changes from this patch belong in a separately identified before/after +record; they must not replace its historical PDF artifacts or timing samples. +These checks do not establish general renderer parity, accessibility, print +device compatibility, or throughput. + +The table handling follows the OpenType specifications for +[`cmap`](https://learn.microsoft.com/en-us/typography/opentype/spec/cmap), +[`post`](https://learn.microsoft.com/en-us/typography/opentype/spec/post), +[`hmtx`](https://learn.microsoft.com/en-us/typography/opentype/spec/hmtx), and +[`hhea`](https://learn.microsoft.com/en-us/typography/opentype/spec/hhea). diff --git a/docs/release/README.md b/docs/release/README.md index b84ac9c..91f675f 100644 --- a/docs/release/README.md +++ b/docs/release/README.md @@ -4,6 +4,11 @@ This directory is the release-operations layer for Fullbleed. It is separate from feature documentation so release decisions can be audited without reading engine internals. +## 2.5.7 Documents + +- `docs/release/2.5.7-runbook.md`: compact TrueType metadata and retained output checks. +- `tools/smoke_font_subsets.py`: independent font-program, text, and pixel comparisons. + ## 2.5.6 Documents - `docs/release/2.5.6-runbook.md`: single-copy synthesized-bold text, affine glyph previews, and retained extraction checks. diff --git a/examples/acme_invoice/output/acme_sample_invoice.pdf b/examples/acme_invoice/output/acme_sample_invoice.pdf index f2eac16..c7511b2 100644 Binary files a/examples/acme_invoice/output/acme_sample_invoice.pdf and b/examples/acme_invoice/output/acme_sample_invoice.pdf differ diff --git a/examples/bank_statement/output/bank_statement.pdf b/examples/bank_statement/output/bank_statement.pdf index a6bfc42..5cc96ec 100644 Binary files a/examples/bank_statement/output/bank_statement.pdf and b/examples/bank_statement/output/bank_statement.pdf differ diff --git a/fullbleed-agent-contract.json b/fullbleed-agent-contract.json index 3add764..eb2fb40 100644 --- a/fullbleed-agent-contract.json +++ b/fullbleed-agent-contract.json @@ -4207,7 +4207,7 @@ "license": "MIT", "name": "Fullbleed PDF Engine", "python_package": "fullbleed", - "version": "2.5.6" + "version": "2.5.7" }, "profiles": { "pdf": { diff --git a/goldens/expected/golden_suite.expected.json b/goldens/expected/golden_suite.expected.json index 559ef9d..5705341 100644 --- a/goldens/expected/golden_suite.expected.json +++ b/goldens/expected/golden_suite.expected.json @@ -1,14 +1,14 @@ { "schema": "fullbleed.golden_suite.v1", - "generated_at_utc": "2026-10-03T15:49:04.157375+00:00", + "generated_at_utc": "2026-10-05T01:29:44.268281Z", "cases": { "acme_invoice": { "case_id": "acme_invoice", "report": "examples/acme_invoice/report.py", "pdf": { "path": "examples/acme_invoice/output/acme_sample_invoice.pdf", - "bytes": 133691, - "sha256": "f7f69432764aa811a53a3c83b9df08743fbf0ae5804d4686809f472b642690fb" + "bytes": 93250, + "sha256": "6199c7ec17f35f4592aa2eb972df169086b3974d25993417cdd40b8ef0d8c379" }, "png": { "count": 1, @@ -36,8 +36,8 @@ "report": "examples/bank_statement/report.py", "pdf": { "path": "examples/bank_statement/output/bank_statement.pdf", - "bytes": 59765, - "sha256": "76b835ce61bc857feb807fdafadacab01a118e7e22abefd937091ae396f3f805" + "bytes": 19365, + "sha256": "2eb3c562674e5e0e7920e7bbb1ea306667c791904ebdcfabd3777e5db6cccb7a" }, "png": { "count": 2, @@ -93,4 +93,4 @@ "css_layers": null } } -} \ No newline at end of file +} diff --git a/llms.txt b/llms.txt index 349f1f6..019eea6 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # Fullbleed PDF Engine -> Fullbleed 2.5.6 is a deterministic, self-contained document-generation runtime for Python and Rust. It turns structured data plus static HTML/CSS into validated PDF documents without a browser. +> Fullbleed 2.5.7 is a deterministic, self-contained document-generation runtime for Python and Rust. It turns structured data plus static HTML/CSS into validated PDF documents without a browser. The installed runtime is authoritative. Do not infer its API from this file or from remembered release knowledge. Start with: diff --git a/pyproject.toml b/pyproject.toml index 7741f90..d0e64b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ backend-path = ["build_backend"] [project] name = "fullbleed" -version = "2.5.6" +version = "2.5.7" description = "Deterministic Python PDF engine for reports, invoices, accessible documents, print-ready output, and compiled VDP" readme = "README.md" requires-python = ">=3.10" diff --git a/src/font_subset.rs b/src/font_subset.rs index ada69dd..c74ec81 100644 --- a/src/font_subset.rs +++ b/src/font_subset.rs @@ -3,7 +3,10 @@ //! The PDF text path emits original glyph IDs through Identity-H. Keeping those IDs stable avoids //! a CID remap table and lets subsetting happen at link time: unused `glyf` records become empty, //! while used glyphs and every recursively referenced composite component retain their original -//! indices. All other tables remain byte-for-byte compatible with the original glyph namespace. +//! indices. Character maps and metrics are compacted within that original glyph namespace; +//! PostScript glyph names are omitted because PDF text selects glyphs by ID or character map. + +mod cmap; use std::collections::{BTreeSet, VecDeque}; @@ -101,7 +104,11 @@ pub(crate) fn subset_truetype( let (new_glyf, new_loca) = rebuild_glyph_tables(glyf, &old_offsets, glyph_total, &keep, loca_is_long)?; - let data = rebuild_sfnt(source, signature, &records, &new_glyf, &new_loca)?; + let hmtx = subset_metrics(&records, glyph_total, &keep)?; + let hhea = subset_horizontal_header(&records, glyf, &old_offsets, &hmtx, &keep)?; + let data = rebuild_sfnt( + signature, &records, &new_glyf, &new_loca, &hmtx, &hhea, &keep, + )?; if data.len() >= source.len() { return None; } @@ -286,12 +293,97 @@ fn rebuild_glyph_tables( Some((glyf, loca)) } +fn subset_metrics( + records: &[TableRecord<'_>], + glyph_total: usize, + keep: &BTreeSet, +) -> Option> { + let hhea = table(records, *b"hhea")?; + let hmtx = table(records, *b"hmtx")?; + let long_count = usize::from(read_u16(hhea, 34)?); + if long_count == 0 || long_count > glyph_total { + return None; + } + let length = long_count.checked_mul(4)? + (glyph_total - long_count).checked_mul(2)?; + let mut result = hmtx.get(..length)?.to_vec(); + // The final long metric's advance also belongs to every trailing bearing-only glyph. + let shared_advance_used = keep.iter().any(|glyph| usize::from(*glyph) >= long_count); + for glyph in 0..glyph_total { + if keep.contains(&u16::try_from(glyph).ok()?) { + continue; + } + if glyph < long_count { + if glyph + 1 != long_count || !shared_advance_used { + write_u16(&mut result, glyph * 4, 0)?; + } + write_u16(&mut result, glyph * 4 + 2, 0)?; + } else { + write_u16(&mut result, long_count * 4 + (glyph - long_count) * 2, 0)?; + } + } + Some(result) +} + +fn subset_post(source: &[u8]) -> Option> { + // OpenType post version 3 retains the 32-byte header and omits glyph names. Font naming, + // licensing records, underline metrics and italic angle remain in their original tables. + let mut result = source.get(..32)?.to_vec(); + write_u32(&mut result, 0, 0x0003_0000)?; + Some(result) +} + +fn subset_horizontal_header( + records: &[TableRecord<'_>], + glyf: &[u8], + offsets: &[usize], + hmtx: &[u8], + keep: &BTreeSet, +) -> Option> { + let mut result = table(records, *b"hhea")?.to_vec(); + let long_count = usize::from(read_u16(&result, 34)?); + let mut max_advance = 0u16; + let mut bounds: Option<(i32, i32, i32)> = None; + for glyph in keep { + let index = usize::from(*glyph); + let advance = read_u16(hmtx, index.min(long_count.checked_sub(1)?) * 4)?; + max_advance = max_advance.max(advance); + let outline = glyf.get(*offsets.get(index)?..*offsets.get(index + 1)?)?; + if outline.is_empty() || read_i16(outline, 0)? == 0 { + continue; + } + let bearing_offset = if index < long_count { + index * 4 + 2 + } else { + long_count * 4 + (index - long_count) * 2 + }; + let left = i32::from(read_i16(hmtx, bearing_offset)?); + let extent = left + i32::from(read_i16(outline, 6)?) - i32::from(read_i16(outline, 2)?); + let right = i32::from(advance) - extent; + bounds = Some(match bounds { + Some((min_left, min_right, max_extent)) => ( + min_left.min(left), + min_right.min(right), + max_extent.max(extent), + ), + None => (left, right, extent), + }); + } + write_u16(&mut result, 10, max_advance)?; + let (left, right, extent) = bounds.unwrap_or((0, 0, 0)); + for (offset, value) in [(12, left), (14, right), (16, extent)] { + write_u16(&mut result, offset, i16::try_from(value).ok()? as u16)?; + } + Some(result) +} + fn rebuild_sfnt( - _source: &[u8], signature: [u8; 4], records: &[TableRecord<'_>], glyf: &[u8], loca: &[u8], + hmtx: &[u8], + hhea: &[u8], + keep: &BTreeSet, ) -> Option> { let mut tables: Vec<([u8; 4], Vec)> = Vec::with_capacity(records.len()); for record in records { @@ -304,6 +396,12 @@ fn rebuild_sfnt( let mut data = match record.tag { tag if tag == *b"glyf" => glyf.to_vec(), tag if tag == *b"loca" => loca.to_vec(), + tag if tag == *b"hmtx" => hmtx.to_vec(), + tag if tag == *b"hhea" => hhea.to_vec(), + tag if tag == *b"post" => subset_post(record.data)?, + tag if tag == *b"cmap" => { + cmap::subset(record.data, keep).unwrap_or_else(|| record.data.to_vec()) + } _ => record.data.to_vec(), }; if record.tag == *b"head" { @@ -443,7 +541,8 @@ fn checksum(data: &[u8]) -> u32 { #[cfg(test)] mod tests { use super::{ - CHECKSUM_MAGIC, checksum, read_i16, read_loca, subset_truetype, table, table_records, + CHECKSUM_MAGIC, TableRecord, checksum, read_i16, read_loca, read_u16, read_u32, + subset_metrics, subset_truetype, table, table_records, }; use crate::sfnt::{Face, GlyphId}; use crate::sfnt_outline::OutlineBuilder; @@ -551,4 +650,61 @@ mod tests { assert!(outline.0 > 0); } } + + #[test] + fn subset_metadata_preserves_mapped_glyphs_metrics_and_font_notices() { + let inter = include_bytes!("../python/fullbleed_assets/fonts/Inter-Variable.ttf"); + for source in [NOTO, VARIABLE_NOTO, inter.as_slice()] { + let original = Face::parse(source, 0).expect("source font"); + let characters: Vec<(char, GlyphId)> = "A z\u{e9}\u{100}\u{3a9}\u{2211}\u{1d49c}" + .chars() + .filter_map(|ch| original.glyph_index(ch as u32).map(|gid| (ch, gid))) + .collect(); + let glyphs = characters.iter().map(|(_, gid)| gid.0).collect(); + let subset = subset_truetype(source, &glyphs).expect("subset"); + let face = Face::parse(&subset.data, 0).expect("subset face"); + let before = table_records(source).unwrap(); + let after = table_records(&subset.data).unwrap(); + assert_eq!(table(&after, *b"name"), table(&before, *b"name")); + let post = table(&after, *b"post").unwrap(); + assert_eq!(post.len(), 32); + assert_eq!(read_u32(post, 0), Some(0x0003_0000)); + assert_eq!(&post[4..], &table(&before, *b"post").unwrap()[4..32]); + assert!( + table(&after, *b"cmap").unwrap().len() < table(&before, *b"cmap").unwrap().len() + ); + assert_eq!(checksum(&subset.data), CHECKSUM_MAGIC); + for (ch, gid) in characters { + assert_eq!(face.glyph_index(ch as u32), Some(gid), "{ch}"); + assert_eq!(face.glyph_hor_advance(gid), original.glyph_hor_advance(gid)); + } + let unused = original.glyph_index('Q' as u32).unwrap(); + assert_eq!(face.glyph_index('Q' as u32), None); + assert_eq!(face.glyph_hor_advance(unused), Some(0)); + } + } + + #[test] + fn sparse_metrics_keep_shared_advance_for_a_used_trailing_glyph() { + let mut hhea = [0u8; 36]; + hhea[35] = 2; + let hmtx = [0x01, 0xf4, 0, 10, 0x02, 0x58, 0, 20, 0, 30, 0, 40]; + let records = [ + TableRecord { + tag: *b"hhea", + data: &hhea, + }, + TableRecord { + tag: *b"hmtx", + data: &hmtx, + }, + ]; + let result = subset_metrics(&records, 4, &BTreeSet::from([0, 3])).unwrap(); + assert_eq!(result, [0x01, 0xf4, 0, 10, 0x02, 0x58, 0, 0, 0, 0, 0, 40]); + let result = subset_metrics(&records, 4, &BTreeSet::from([0])).unwrap(); + assert_eq!(read_u16(&result, 4), Some(0)); + assert_eq!(&result[..4], &hmtx[..4]); + assert!(subset_metrics(&records, 1, &BTreeSet::from([0])).is_none()); + assert!(subset_metrics(&records, 5, &BTreeSet::from([0])).is_none()); + } } diff --git a/src/font_subset/cmap.rs b/src/font_subset/cmap.rs new file mode 100644 index 0000000..6e9bd1c --- /dev/null +++ b/src/font_subset/cmap.rs @@ -0,0 +1,329 @@ +//! Prune common character maps without renumbering glyphs or changing encoding records. +//! +//! Other formats retain their original subtable bytes. Malformed input or a larger replacement +//! falls back to the source cmap, just as unsupported outlines retain the full font program. + +use super::{read_u16, read_u32, write_u16, write_u32}; +use std::collections::{BTreeMap, BTreeSet}; + +pub(super) fn subset(source: &[u8], keep: &BTreeSet) -> Option> { + if read_u16(source, 0)? != 0 { + return None; + } + let count = usize::from(read_u16(source, 2)?); + let header_size = 4usize.checked_add(count.checked_mul(8)?)?; + let mut output = source.get(..header_size)?.to_vec(); + let mut offsets = BTreeMap::new(); + for index in 0..count { + let offset = usize::try_from(read_u32(source, 4 + index * 8 + 4)?).ok()?; + let new_offset = if let Some(offset) = offsets.get(&offset) { + *offset + } else { + let tail = source.get(offset..)?; + let format = read_u16(tail, 0)?; + let length = match format { + 0 | 2 | 4 | 6 => usize::from(read_u16(tail, 2)?), + 8 | 10 | 12 | 13 => usize::try_from(read_u32(tail, 4)?).ok()?, + 14 => usize::try_from(read_u32(tail, 2)?).ok()?, + _ => return None, + }; + let original = tail.get(..length)?; + let replacement = match format { + 4 => subset_format4(original, keep), + 12 => subset_format12(original, keep), + _ => None, + }; + let bytes = replacement + .as_deref() + .filter(|bytes| bytes.len() < original.len()) + .unwrap_or(original); + if output.len().checked_add(bytes.len())? > source.len() { + return None; + } + let new_offset = u32::try_from(output.len()).ok()?; + output.extend_from_slice(bytes); + offsets.insert(offset, new_offset); + new_offset + }; + write_u32(&mut output, 4 + index * 8 + 4, new_offset)?; + } + (output.len() < source.len()).then_some(output) +} + +fn subset_format4(source: &[u8], keep: &BTreeSet) -> Option> { + let count_x2 = read_u16(source, 6)?; + if count_x2 == 0 || count_x2 % 2 != 0 { + return None; + } + let count = usize::from(count_x2 / 2); + let starts = 16 + count * 2; + let deltas = starts + count * 2; + let ranges = deltas + count * 2; + source.get(..ranges + count * 2)?; + let mut segments: Vec<(u16, u16, u16)> = Vec::new(); + let mut previous_end = None; + for index in 0..count { + let start = read_u16(source, starts + index * 2)?; + let end = read_u16(source, 14 + index * 2)?; + if start > end || previous_end.is_some_and(|prior| start <= prior) { + return None; + } + previous_end = Some(end); + let delta = read_u16(source, deltas + index * 2)?; + let range_word = ranges + index * 2; + let range = usize::from(read_u16(source, range_word)?); + for code in start..=end { + let glyph = if range == 0 { + code.wrapping_add(delta) + } else { + let glyph = read_u16(source, range_word + range + usize::from(code - start) * 2)?; + if glyph == 0 { + 0 + } else { + glyph.wrapping_add(delta) + } + }; + if glyph == 0 || !keep.contains(&glyph) { + continue; + } + let delta = glyph.wrapping_sub(code); + if let Some(last) = segments + .last_mut() + .filter(|last| last.1.checked_add(1) == Some(code) && last.2 == delta) + { + last.1 = code; + } else { + segments.push((code, code, delta)); + } + } + } + if !segments.last().is_some_and(|last| last.1 == u16::MAX) { + segments.push((u16::MAX, u16::MAX, 1)); + } + let count = u16::try_from(segments.len()).ok()?; + let length = 16usize.checked_add(usize::from(count).checked_mul(8)?)?; + let mut output = vec![0; usize::from(u16::try_from(length).ok()?)]; + write_u16(&mut output, 0, 4)?; + write_u16(&mut output, 2, u16::try_from(length).ok()?)?; + write_u16(&mut output, 4, read_u16(source, 4)?)?; + write_u16(&mut output, 6, count.checked_mul(2)?)?; + let mut power = 1u16; + let mut selector = 0u16; + while power <= count / 2 { + power *= 2; + selector += 1; + } + write_u16(&mut output, 8, power * 2)?; + write_u16(&mut output, 10, selector)?; + write_u16(&mut output, 12, count * 2 - power * 2)?; + for (index, (start, end, delta)) in segments.iter().enumerate() { + write_u16(&mut output, 14 + index * 2, *end)?; + write_u16(&mut output, 16 + usize::from(count) * 2 + index * 2, *start)?; + write_u16(&mut output, 16 + usize::from(count) * 4 + index * 2, *delta)?; + } + Some(output) +} + +fn subset_format12(source: &[u8], keep: &BTreeSet) -> Option> { + let count = usize::try_from(read_u32(source, 12)?).ok()?; + source.get(..16usize.checked_add(count.checked_mul(12)?)?)?; + let mut groups: Vec<(u32, u32, u32)> = Vec::new(); + let mut previous_end = None; + for index in 0..count { + let start = read_u32(source, 16 + index * 12)?; + let end = read_u32(source, 20 + index * 12)?; + let first = read_u32(source, 24 + index * 12)?; + if start > end || end > 0x10ffff || previous_end.is_some_and(|prior| start <= prior) { + return None; + } + previous_end = Some(end); + let last = first.checked_add(end - start)?; + if first > u32::from(u16::MAX) { + continue; + } + for glyph in keep.range((first as u16)..=(last.min(u32::from(u16::MAX)) as u16)) { + if *glyph == 0 { + continue; + } + let code = start.checked_add(u32::from(*glyph) - first)?; + if let Some(last) = groups.last_mut().filter(|last| { + last.1.checked_add(1) == Some(code) + && last.2.checked_add(code - last.0) == Some(u32::from(*glyph)) + }) { + last.1 = code; + } else { + groups.push((code, code, u32::from(*glyph))); + if groups.len() > count { + return None; + } + } + } + } + let mut output = source.get(..16)?.to_vec(); + for (start, end, glyph) in &groups { + output.extend_from_slice(&start.to_be_bytes()); + output.extend_from_slice(&end.to_be_bytes()); + output.extend_from_slice(&glyph.to_be_bytes()); + } + let length = u32::try_from(output.len()).ok()?; + write_u32(&mut output, 4, length)?; + write_u32(&mut output, 12, u32::try_from(groups.len()).ok()?)?; + Some(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn array_cmap4() -> Vec { + // A/B/C use a glyph array; U+0100/0101 use a delta. The final segment is .notdef. + [ + 4u16, 46, 0, 6, 4, 1, 2, 0x43, 0x101, 0xffff, 0, 0x41, 0x100, 0xffff, 0, 1, 1, 6, 0, 0, + 3, 7, 9, + ] + .into_iter() + .flat_map(u16::to_be_bytes) + .collect() + } + + #[test] + fn format4_preserves_array_and_delta_mappings_and_the_terminal_segment() { + let result = subset_format4(&array_cmap4(), &BTreeSet::from([0, 3, 9, 0x101])).unwrap(); + let expected: Vec = [ + 4u16, 48, 0, 8, 8, 2, 0, 0x41, 0x43, 0x100, 0xffff, 0, 0x41, 0x43, 0x100, 0xffff, + 0xffc2, 0xffc6, 1, 1, 0, 0, 0, 0, + ] + .into_iter() + .flat_map(u16::to_be_bytes) + .collect(); + assert_eq!(result, expected); + // A replacement with more segment records must not inflate an existing cmap. + let mut source = vec![0, 0, 0, 1, 0, 3, 0, 1, 0, 0, 0, 12]; + source.extend_from_slice(&array_cmap4()); + assert!(subset(&source, &BTreeSet::from([0, 3, 9, 0x101])).is_none()); + } + + #[test] + fn format12_keeps_non_bmp_glyphs_and_merges_consecutive_survivors() { + let source: Vec = [ + 0x000c0000u32, + 52, + 0, + 3, + 0x41, + 0x43, + 3, + 0x1f600, + 0x1f605, + 40, + 0x10ffff, + 0x10ffff, + 70, + ] + .into_iter() + .flat_map(u32::to_be_bytes) + .collect(); + let result = subset_format12(&source, &BTreeSet::from([0, 4, 42, 43, 70])).unwrap(); + let expected: Vec = [ + 0x000c0000u32, + 52, + 0, + 3, + 0x42, + 0x42, + 4, + 0x1f602, + 0x1f603, + 42, + 0x10ffff, + 0x10ffff, + 70, + ] + .into_iter() + .flat_map(u32::to_be_bytes) + .collect(); + assert_eq!(result, expected); + assert_eq!( + subset_format12(&source, &BTreeSet::from([0])) + .unwrap() + .len(), + 16 + ); + } + + #[test] + fn expanding_format12_rewrite_declines_before_allocating_a_larger_map() { + let source: Vec = [0x000c0000u32, 28, 0, 1, 0x41, 0x45, 3] + .into_iter() + .flat_map(u32::to_be_bytes) + .collect(); + assert!(subset_format12(&source, &BTreeSet::from([3, 5, 7])).is_none()); + let outside_unicode: Vec = [0x000c0000u32, 28, 0, 1, 0, 0xffffffff, 0] + .into_iter() + .flat_map(u32::to_be_bytes) + .collect(); + assert!(subset_format12(&outside_unicode, &BTreeSet::from([1])).is_none()); + } + + #[test] + fn duplicate_encoding_records_share_a_compacted_subtable() { + let mut source = vec![0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 20, 0, 3, 0, 1, 0, 0, 0, 20]; + source.extend_from_slice(&array_cmap4()); + let result = subset(&source, &BTreeSet::from([3])).unwrap(); + assert_eq!(read_u32(&result, 8), Some(20)); + assert_eq!(read_u32(&result, 16), Some(20)); + assert_eq!(&result[..20], &source[..20]); + assert_eq!(result.len(), 20 + 32); + } + + #[test] + fn legacy_encoding_subtable_is_copied_byte_for_byte() { + let mut source = vec![0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 20, 0, 1, 0, 0, 0, 0, 0, 66]; + source.extend_from_slice(&array_cmap4()); + let legacy: Vec = [6u16, 16, 18, 0x41, 3, 3, 7, 9] + .into_iter() + .flat_map(u16::to_be_bytes) + .collect(); + source.extend_from_slice(&legacy); + let result = subset(&source, &BTreeSet::from([3])).unwrap(); + assert_eq!(read_u32(&result, 16), Some(52)); + assert_eq!(&result[52..], legacy.as_slice()); + assert_eq!(&result[12..16], &source[12..16]); + } + + #[test] + fn format4_array_delta_wraps_and_missing_glyph_stays_missing() { + let mut source = array_cmap4(); + write_u16(&mut source, 28, 0xfffe).unwrap(); + write_u16(&mut source, 42, 0).unwrap(); + let result = subset_format4(&source, &BTreeSet::from([0, 1, 7])).unwrap(); + // A -> 3 - 2 = 1, B remains missing, C -> 9 - 2 = 7. + assert_eq!(read_u16(&result, 6), Some(6)); + assert_eq!( + [read_u16(&result, 14), read_u16(&result, 16)], + [Some(0x41), Some(0x43)] + ); + assert_eq!( + [read_u16(&result, 28), read_u16(&result, 30)], + [Some(0xffc0), Some(0xffc4)] + ); + } + + #[test] + fn malformed_character_maps_decline_compaction_without_panicking() { + let valid = array_cmap4(); + for end in 0..valid.len() { + // Some prefixes omit only unused glyphs; those may still have a valid retained map. + let _ = subset_format4(&valid[..end], &BTreeSet::from([3, 7, 9])); + } + let mut overlapping = valid.clone(); + write_u16(&mut overlapping, 24, 0x42).unwrap(); + assert!(subset_format4(&overlapping, &BTreeSet::from([3])).is_none()); + assert!(subset(&[0, 0, 0, 1], &BTreeSet::new()).is_none()); + let overflow: Vec = [0x000c0000u32, 28, 0, 1, 0, u32::MAX, 1] + .into_iter() + .flat_map(u32::to_be_bytes) + .collect(); + assert!(subset_format12(&overflow, &BTreeSet::from([3])).is_none()); + } +} diff --git a/tools/smoke_font_subsets.py b/tools/smoke_font_subsets.py new file mode 100644 index 0000000..34de0ca --- /dev/null +++ b/tools/smoke_font_subsets.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Check embedded subset outlines, metrics, extraction and pixels with independent readers. + +Development-only dependencies: pypdf, pypdfium2, fonttools, pillow. No runtime dependencies +are added to Fullbleed. A baseline records actual output from an earlier installed wheel. +""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +from html import escape +from importlib import metadata, resources +from io import BytesIO +import json +from pathlib import Path +import re +import struct +import sys + +import fullbleed +from fontTools.ttLib import TTFont +import pypdfium2 as pdfium +from pypdf import PdfReader + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'benchmarks/pdf_renderers')) +import fixtures +from validate import check_pdf + + +def digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def compact(text: str) -> str: + return re.sub(r'\s+', '', text) + + +def cases(): + for name in fixtures.NAMES: + html, contract = fixtures.document(name) + yield name, fixtures.FONT, html, fixtures.stylesheet(), contract, {} + fonts = resources.files('fullbleed_assets').joinpath('fonts') + samples = [ + ('inter-variable', 'Inter-Variable.ttf', 'Inter', 'Invoice INV-2042 Résumé café Ångström Ω Ж 123.45', {}), + ('noto-variable', 'NotoSans-Regular.ttf', 'Noto Sans', 'Résumé café Ångström Ā Ć Ω Ж 123.45', {}), + ('math-static', 'NotoSansMath-Regular.ttf', 'Noto Sans Math', 'A 𝒜 ∑ ∫ ≠ → 123.45', {}), + ('symbols-static', 'NotoSansSymbols-Regular.ttf', 'Noto Sans Symbols', '♀ →', {}), + ('symbols2-static', 'NotoSansSymbols2-Regular.ttf', 'Noto Sans Symbols2', '☀ ★ ☑ 🂡 ⚀', {}), + ('inter-unshaped', 'Inter-Variable.ttf', 'Inter', 'Invoice café déjà vu 123.45', + {'shape_text': False}), + ] + for name, filename, family, text, options in samples: + html = f'

{escape(text)}

' + css = f'@page {{size:A4;margin:36pt}} body {{font-family:"{family}";font-size:18pt}}' + yield name, Path(str(fonts.joinpath(filename))), html, css, {'text': text}, options + + +def fonts_in(resources_dict, seen=None): + seen = set() if seen is None else seen + resources_dict = resources_dict.get_object() + fonts = resources_dict.get('/Font', {}) + fonts = fonts.get_object() if hasattr(fonts, 'get_object') else fonts + for ref in fonts.values(): + font = ref.get_object() + identity = getattr(ref, 'idnum', id(font)) + if identity in seen: + continue + seen.add(identity) + for descendant in font.get('/DescendantFonts', [font]): + descendant = descendant.get_object() + descriptor = descendant.get('/FontDescriptor') + if descriptor and '/FontFile2' in descriptor.get_object(): + yield font, descendant, descriptor.get_object()['/FontFile2'].get_object() + xobjects = resources_dict.get('/XObject', {}) + xobjects = xobjects.get_object() if hasattr(xobjects, 'get_object') else xobjects + for ref in xobjects.values(): + obj = ref.get_object() + if obj.get('/Subtype') == '/Form' and '/Resources' in obj: + yield from fonts_in(obj['/Resources'], seen) + + +def used_glyphs(font, descendant, original): + used = {0} + if '/W' in descendant: + widths = descendant['/W'] + index = 0 + while index < len(widths): + first = int(widths[index]) + value = widths[index + 1] + if isinstance(value, list): + used.update(range(first, first + len(value))) + index += 2 + else: + used.update(range(first, int(value) + 1)) + index += 3 + else: + assert font['/Subtype'] == '/TrueType' and font['/Encoding'] == '/WinAnsiEncoding' + cmap = original.getBestCmap() + for code in range(256): + try: + character = bytes([code]).decode('cp1252') + except UnicodeDecodeError: + continue + if ord(character) in cmap: + used.add(original.getGlyphID(cmap[ord(character)])) + order = original.getGlyphOrder() + pending = list(used) + while pending: + gid = pending.pop() + glyph = original['glyf'][order[gid]] + if glyph.isComposite(): + for component in glyph.components: + child = original.getGlyphID(component.glyphName) + if child not in used: + used.add(child) + pending.append(child) + return used + + +def check_font(font, descendant, stream, source, legacy): + data = stream.get_data() + original = TTFont(source) + embedded = TTFont(BytesIO(data), checkChecksums=2) + assert embedded['maxp'].numGlyphs == original['maxp'].numGlyphs + assert embedded.reader['name'] == original.reader['name'], 'Font notices/names changed' + padded = data + b'\0' * ((-len(data)) % 4) + assert sum(struct.unpack(f'>{len(padded) // 4}I', padded)) & 0xffffffff == 0xb1b0afba + keep = used_glyphs(font, descendant, original) + before_order, after_order = original.getGlyphOrder(), embedded.getGlyphOrder() + before_glyf, after_glyf = original.reader['glyf'], embedded.reader['glyf'] + before_loca, after_loca = original['loca'].locations, embedded['loca'].locations + for gid in keep: + before = before_glyf[before_loca[gid]:before_loca[gid + 1]] + after = after_glyf[after_loca[gid]:after_loca[gid + 1]] + assert before.rstrip(b'\0') == after.rstrip(b'\0'), f'Outline/instructions changed: {gid}' + assert original['hmtx'][before_order[gid]] == embedded['hmtx'][after_order[gid]], f'Metrics changed: {gid}' + for old_table, new_table in zip(original['cmap'].tables, embedded['cmap'].tables, strict=True): + assert (old_table.platformID, old_table.platEncID, old_table.format) == ( + new_table.platformID, new_table.platEncID, new_table.format) + if not hasattr(old_table, 'cmap'): + continue + for point, name in old_table.cmap.items(): + gid = original.getGlyphID(name) + if gid in keep: + assert point in new_table.cmap, f'Character mapping lost: {point}' + assert embedded.getGlyphID(new_table.cmap[point]) == gid + if not legacy: + assert embedded['post'].formatType == 3 and len(embedded.reader['post']) == 32 + fields = ['advanceWidthMax', 'minLeftSideBearing', 'minRightSideBearing', 'xMaxExtent'] + original_header = tuple(getattr(embedded['hhea'], name) for name in fields) + embedded['hhea'].recalc(embedded) + assert original_header == tuple(getattr(embedded['hhea'], name) for name in fields) + long_count = embedded['hhea'].numberOfHMetrics + shared = any(gid >= long_count for gid in keep) + for gid in range(embedded['maxp'].numGlyphs): + if gid in keep: + continue + advance, bearing = embedded['hmtx'][after_order[gid]] + assert bearing == 0 + if gid < long_count - 1 or (gid == long_count - 1 and not shared): + assert advance == 0 + result = {'original_bytes': source.stat().st_size, 'program_bytes': len(data), + 'encoded_bytes': len(stream._data), 'retained_glyphs_and_components': len(keep), + 'tables': {tag: embedded.reader.tables[tag].length for tag in embedded.reader.keys()}, + 'original_glyph_ids_outlines_metrics_and_mappings_preserved': True, + 'font_name_and_notice_table_preserved': True} + original.close() + embedded.close() + return result + + +def run(args): + args.out.mkdir(parents=True, exist_ok=False) + baseline = json.loads((args.baseline / 'verification.json').read_text()) if args.baseline else None + report = {'schema': 'fullbleed.font_subset_smoke.v1', 'ok': False, + 'checked_at': datetime.now(timezone.utc).isoformat(), + 'versions': {name: metadata.version(name) for name in ['fullbleed', 'pypdf', 'pypdfium2', 'fonttools', 'pillow']}, + 'baseline': str(args.baseline) if args.baseline else None, + 'legacy_metadata_allowed': args.allow_legacy_metadata, 'cases': [], + 'scope': 'Retained fixtures and independent reader/font checks; not a general rendering or conformance guarantee.'} + try: + for name, font_path, html, css, contract, options in cases(): + folder = args.out / name + folder.mkdir() + (folder / 'source.html').write_text(html, encoding='utf-8') + (folder / 'source.css').write_text(css, encoding='utf-8') + engine = fullbleed.PdfEngine(font_files=[str(font_path)], + document_timestamp='2026-10-05T01:10:00Z', **options) + pdf = bytes(engine.render_pdf(html, css)) + path = folder / 'output.pdf' + path.write_bytes(pdf) + assert pdf == bytes(engine.render_pdf(html, css)), f'{name}: repeat bytes changed' + reader = PdfReader(path) + extracted = ''.join(page.extract_text() for page in reader.pages) + if 'text' in contract: + assert compact(extracted) == compact(contract['text']), (name, extracted) + else: + check = check_pdf(path, contract, fixtures.TOKEN) + assert check['passed'], check['failures'] + checked_fonts = [] + seen = set() + for page in reader.pages: + for font, descendant, stream in fonts_in(page['/Resources'], seen): + checked_fonts.append(check_font(font, descendant, stream, font_path, args.allow_legacy_metadata)) + assert checked_fonts, f'{name}: no embedded fonts checked' + pixels = [] + pdfium_text = [] + with pdfium.PdfDocument(path) as document: + for index in range(len(document)): + page = document[index] + text = page.get_textpage() + pdfium_text.append(text.get_text_range()) + text.close() + bitmap = page.render(scale=1.25) + image = bitmap.to_pil() + image.save(folder / f'pdfium-{index + 1}.png') + pixels.append(digest(image.tobytes())) + bitmap.close() + page.close() + assert compact(''.join(pdfium_text)) == compact(extracted), f'{name}: readers disagree' + native = [bytes(png) for png in engine.render_finalized_pdf_image_pages(str(path), 96)] + for index, png in enumerate(native, 1): + (folder / f'native-{index}.png').write_bytes(png) + item = {'name': name, 'pdf_bytes': len(pdf), 'pdf_sha256': digest(pdf), + 'font_source_sha256': digest(font_path.read_bytes()), + 'html_sha256': digest(html.encode()), 'css_sha256': digest(css.encode()), + 'pages': len(reader.pages), 'text': extracted, 'pdfium_pixel_sha256': pixels, + 'native_png_sha256': [digest(png) for png in native], 'fonts': checked_fonts, + 'repeat_pdf_bytes_identical': True, 'independent_text_readers_agree': True} + if baseline: + prior = next(case for case in baseline['cases'] if case['name'] == name) + for key in ['font_source_sha256', 'html_sha256', 'css_sha256', 'pages', 'text', 'pdfium_pixel_sha256', 'native_png_sha256']: + assert item[key] == prior[key], f'{name}: changed {key}' + assert item['pdf_bytes'] < prior['pdf_bytes'], f'{name}: PDF did not shrink' + item['baseline_pdf_bytes'] = prior['pdf_bytes'] + item['pdf_reduction_percent'] = round(100 * (1 - len(pdf) / prior['pdf_bytes']), 2) + item['baseline_text_and_pixels_identical'] = True + report['cases'].append(item) + print(json.dumps({'case': name, 'bytes': len(pdf), 'pages': item['pages'], 'fonts_checked': len(checked_fonts)}), flush=True) + report['ok'] = True + finally: + (args.out / 'verification.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + return report + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--baseline', type=Path) + parser.add_argument('--allow-legacy-metadata', action='store_true') + args = parser.parse_args() + result = run(args) + print(json.dumps({'ok': result['ok'], 'cases': len(result['cases'])}))