diff --git a/crates/pdf/tests/font_tables.rs b/crates/pdf/tests/font_tables.rs new file mode 100644 index 000000000..30f234668 --- /dev/null +++ b/crates/pdf/tests/font_tables.rs @@ -0,0 +1,110 @@ +//! The font programs the writer embeds keep the source font's `OS/2` table (#1781 in DesignCraft): +//! its `fsType` is where the font states its embedding rights, and the subsetter writes none. +#![cfg(feature = "write")] +#![allow(clippy::unwrap_used, clippy::panic)] + +use backend::geom::Point; +use backend::page::PageSettings; +use backend::text::{GlyphId, KrillaGlyph}; +use craft_pdf::write::{self, Metadata, Writer, backend}; +use hayro_syntax::object::{Object, Stream}; + +/// A two-glyph TrueType font with an `OS/2` table whose `fsType` is 4 (Preview & Print). +const TINY_GLYF: &[u8] = include_bytes!("../../fonts-build/tests/fixture/subset/Tiny-glyf.ttf"); + +/// `text` set in `font_bytes`, one glyph per character, as a one-page PDF. +fn document(font_bytes: &[u8], text: &str) -> Vec { + let face = ttf_parser::Face::parse(font_bytes, 0).unwrap(); + let glyphs: Vec<_> = text + .char_indices() + .map(|(start, c)| { + let id = face.glyph_index(c).unwrap_or_else(|| panic!("the fixture covers {c:?}")); + let advance = f32::from(face.glyph_hor_advance(id).unwrap()) / f32::from(face.units_per_em()); + KrillaGlyph::new(GlyphId::new(u32::from(id.0)), advance, 0.0, 0.0, 0.0, start..start + c.len_utf8(), None) + }) + .collect(); + let font = write::font(font_bytes, 0, None).unwrap(); + let mut writer = Writer::new(backend::SerializeSettings::default(), Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(200.0, 100.0).unwrap()); + let mut surface = page.surface(); + surface.draw_glyphs(Point::from_xy(20.0, 50.0), &glyphs, font, text, 18.0, false); + surface.finish(); + page.finish(); + writer.finish().unwrap() +} + +/// The decoded `FontFile2` programs of `pdf`, through the font descriptors that name them. +fn font_files(pdf: &[u8]) -> Vec> { + let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); + pdf.objects() + .into_iter() + .filter_map(|o| match o { + Object::Dict(d) => d.get::>(b"FontFile2"), + _ => None, + }) + .map(|s| s.decoded().unwrap().to_vec()) + .collect() +} + +/// `(tag, offset, length, checksum)` of each table in the sfnt `font`. +fn tables(font: &[u8]) -> Vec<([u8; 4], usize, usize, u32)> { + let be32 = |at: usize| u32::from_be_bytes(font[at..at + 4].try_into().unwrap()); + let n = usize::from(u16::from_be_bytes(font[4..6].try_into().unwrap())); + (0..n) + .map(|k| (font[12 + 16 * k..16 + 16 * k].try_into().unwrap(), be32(20 + 16 * k) as usize, be32(24 + 16 * k) as usize, be32(16 + 16 * k))) + .collect() +} + +fn checksum(data: &[u8]) -> u32 { + data.chunks(4).fold(0u32, |sum, c| { + let mut word = [0u8; 4]; + word[..c.len()].copy_from_slice(c); + sum.wrapping_add(u32::from_be_bytes(word)) + }) +} + +fn table<'a>(font: &'a [u8], tag: &[u8; 4]) -> Option<&'a [u8]> { + tables(font).into_iter().find(|t| &t.0 == tag).map(|(_, off, len, _)| &font[off..off + len]) +} + +#[test] +fn an_embedded_truetype_subset_keeps_the_os2_table_and_its_fstype() { + let face = ttf_parser::Face::parse(TINY_GLYF, 0).unwrap(); + let text: String = ('!'..='~').filter(|c| face.glyph_index(*c).is_some()).collect(); + assert!(!text.is_empty(), "the fixture maps some ASCII"); + let files = font_files(&document(TINY_GLYF, &text)); + assert_eq!(files.len(), 1, "one embedded TrueType program"); + let sub = &files[0]; + let os2 = table(sub, b"OS/2").expect("the subset carries an OS/2 table"); + assert_eq!(os2, table(TINY_GLYF, b"OS/2").unwrap(), "the source's OS/2, unchanged"); + assert_eq!(u16::from_be_bytes(os2[8..10].try_into().unwrap()), 4, "fsType: Preview & Print"); + // A valid sfnt: tags in order, each table's checksum right, the whole font summing to the + // magic number through head.checkSumAdjustment. + let t = tables(sub); + assert!( + t.windows(2).all(|w| w[0].0 < w[1].0), + "tables in tag order: {:?}", + t.iter().map(|t| String::from_utf8_lossy(&t.0).to_string()).collect::>() + ); + for (tag, off, len, sum) in &t { + let mut data = sub[*off..off + len].to_vec(); + if tag == b"head" { + data[8..12].fill(0); + } + assert_eq!(checksum(&data), *sum, "{} checksum", String::from_utf8_lossy(tag)); + assert_eq!(off % 4, 0, "{} is 4-byte aligned", String::from_utf8_lossy(tag)); + } + assert_eq!(checksum(sub), 0xB1B0_AFBA, "whole-font checksum"); + // And it still reads as a font with the glyphs it was given. + let reread = ttf_parser::Face::parse(sub, 0).unwrap(); + // A subset, not the source font embedded whole: of the fixture's 11 glyphs, .notdef and the + // ones the text uses, and only their outlines. + assert!( + 2 <= reread.number_of_glyphs() && reread.number_of_glyphs() < face.number_of_glyphs(), + "{} of {} glyphs", + reread.number_of_glyphs(), + face.number_of_glyphs() + ); + assert!(table(sub, b"glyf").unwrap().len() < table(TINY_GLYF, b"glyf").unwrap().len(), "only the used glyphs' outlines"); + assert_eq!(reread.permissions(), face.permissions()); +} diff --git a/vendor/krilla/README.craft.md b/vendor/krilla/README.craft.md index a2bf5c6a6..9b21d4f4a 100644 --- a/vendor/krilla/README.craft.md +++ b/vendor/krilla/README.craft.md @@ -27,6 +27,7 @@ itself omits those texts. Its examples and four CC0 ICC profiles are retained wi | `patches/15-pdfx-conformance.patch` | PDF/X conformance key | `serialize.rs` | | `patches/16-modification-date.patch` | Modification date | `interchange/metadata.rs` | | `patches/17-color-precision.patch` | Color precision | `graphics/{color,shading_function}.rs` | +| `patches/18-subset-os2.patch` | Embedding permissions in TrueType subsets | `text/cid.rs` | Each patch applies with `patch -p1` from this directory, in numeric order, to the pristine crate. `SOURCE.json` records, for every retained file, the upstream hash (`upstream_sha256`) @@ -434,3 +435,19 @@ as a multiple of 1/255: C75 becomes 0.7490196 and C4 M4 Y4 K4 becomes 0.03921568 Tests: `crates/pdf/tests/print_color.rs` (`f32_colors_*`, `gray_stops_keep_their_exact_black_in_cmyk_shadings`, `mesh_*`, `rgb_mesh_is_a_device_rgb_shading`). + +## 18: embedding permissions in TrueType subsets + +The subsetter writes only the tables it subsets (`glyf`/`loca`, `head`, `hmtx`, `maxp`, `name`, +`post`, and `cvt `, `fpgm`, `prep` unchanged), so a `FontFile2` program had no `OS/2` table and +the font's embedding permissions (`OS/2.fsType`) never reached the PDF: a Preview & Print or +Restricted font looked unrestricted to anything reading the file later (storytold/designcraft#1781). + +`CIDFont` now adds the source font's `OS/2` table, byte for byte, to a TrueType subset that lacks +one. `with_table` rewrites the table directory in tag order with its binary-search fields, +aligns each table to four bytes with its checksum, and recomputes `head.checkSumAdjustment`. A +font with no `OS/2` is embedded as before. CFF programs are still embedded bare (`FontFile3` +`/CIDFontType0C`), which has no table directory to carry it. + +craft-pdf's `font_tables` test embeds a TrueType font with `fsType` 4 and checks the program's +`OS/2` matches the source, its table order and checksums, and that it still parses. diff --git a/vendor/krilla/SOURCE.json b/vendor/krilla/SOURCE.json index c3b497bb2..6a0fc87ae 100644 --- a/vendor/krilla/SOURCE.json +++ b/vendor/krilla/SOURCE.json @@ -332,7 +332,7 @@ { "path": "src/text/cid.rs", "upstream_sha256": "5332282160b75e2a24345b546ead219476768829bef881562f5658dd50ce97f2", - "sha256": "5332282160b75e2a24345b546ead219476768829bef881562f5658dd50ce97f2" + "sha256": "4bb482edddaf1cac916cb110e8139c90c53582f0c1c7afc6fa1cf6b037ccae5e" }, { "path": "src/text/font.rs", diff --git a/vendor/krilla/patches/18-subset-os2.patch b/vendor/krilla/patches/18-subset-os2.patch new file mode 100644 index 000000000..21a50a736 --- /dev/null +++ b/vendor/krilla/patches/18-subset-os2.patch @@ -0,0 +1,104 @@ +diff --git a/src/text/cid.rs b/src/text/cid.rs +index 2c52127..dbd4e37 100644 +--- a/src/text/cid.rs ++++ b/src/text/cid.rs +@@ -238,6 +238,8 @@ impl CIDFont { + let num_glyphs = subsetted.num_glyphs(); + let subsetted_data = subsetted.font_data().0; + ++ // Outlives the stream, which may borrow it. ++ let with_os2; + let font_stream = { + let mut data = subsetted_data.as_ref().as_ref(); + +@@ -248,6 +250,14 @@ impl CIDFont { + + if let Some(cff) = subsetted_ref.data_for_tag(Cff::TAG) { + data = cff.as_bytes(); ++ } else if let Some(os2) = self.font.font_ref().data_for_tag(OS2_TAG) { ++ // The subsetter writes no `OS/2`, so the font's embedding permissions ++ // (`fsType`) would not reach the PDF: a Preview & Print or Restricted font ++ // would look unrestricted to what reads it later. Keep the source's table. ++ if let Some(font) = with_table(data, OS2_TAG.to_be_bytes(), os2.as_bytes()) { ++ with_os2 = font; ++ data = &with_os2; ++ } + } + + FilterStreamBuilder::new_from_binary_data(data).finish(&sc.serialize_settings()) +@@ -433,6 +443,75 @@ pub(crate) fn base_font_name(font: &Font, data: &T) -> String { + format!("{subset_tag}+{trimmed}") + } + ++const OS2_TAG: skrifa::raw::types::Tag = skrifa::raw::types::Tag::new(b"OS/2"); ++ ++/// The sfnt `font` with `table` added under `tag`, or `None` when `font` can't be read as one or ++/// already has that table. The table directory is written in tag order with its binary-search ++/// fields, every table 4-byte aligned with its checksum, and `head.checkSumAdjustment` ++/// recomputed (OpenType, "Table directory" and "Calculating checksums"). ++fn with_table(font: &[u8], tag: [u8; 4], table: &[u8]) -> Option> { ++ let be16 = |at: usize| Some(u16::from_be_bytes(font.get(at..at + 2)?.try_into().ok()?)); ++ let be32 = |at: usize| Some(u32::from_be_bytes(font.get(at..at + 4)?.try_into().ok()?)); ++ let count = usize::from(be16(4)?); ++ let mut tables: Vec<([u8; 4], &[u8])> = Vec::with_capacity(count + 1); ++ for k in 0..count { ++ let record = 12 + 16 * k; ++ let t: [u8; 4] = font.get(record..record + 4)?.try_into().ok()?; ++ let (offset, length) = (be32(record + 8)? as usize, be32(record + 12)? as usize); ++ tables.push((t, font.get(offset..offset.checked_add(length)?)?)); ++ } ++ if tables.iter().any(|(t, _)| *t == tag) { ++ return None; ++ } ++ tables.push((tag, table)); ++ tables.sort_by_key(|(t, _)| *t); ++ let n = u16::try_from(tables.len()).ok()?; ++ let entry_selector = 15 - n.leading_zeros() as u16; ++ let search_range = (1u16 << entry_selector).checked_mul(16)?; ++ let range_shift = n.checked_mul(16)?.checked_sub(search_range)?; ++ let checksum = |data: &[u8]| { ++ data.chunks(4).fold(0u32, |sum, c| { ++ let mut word = [0u8; 4]; ++ word[..c.len()].copy_from_slice(c); ++ sum.wrapping_add(u32::from_be_bytes(word)) ++ }) ++ }; ++ let mut out = Vec::with_capacity(font.len() + table.len() + 16 + 3); ++ out.extend_from_slice(font.get(0..4)?); ++ for v in [n, search_range, entry_selector, range_shift] { ++ out.extend_from_slice(&v.to_be_bytes()); ++ } ++ let mut offset = 12 + 16 * tables.len(); ++ let mut head_at = None; ++ for (t, data) in &tables { ++ let mut data = data.to_vec(); ++ if t == b"head" { ++ // checkSumAdjustment is zero while the checksums are taken ++ data.get_mut(8..12)?.fill(0); ++ head_at = Some(offset); ++ } ++ out.extend_from_slice(t); ++ out.extend_from_slice(&checksum(&data).to_be_bytes()); ++ out.extend_from_slice(&u32::try_from(offset).ok()?.to_be_bytes()); ++ out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_be_bytes()); ++ offset = offset.checked_add(data.len().next_multiple_of(4))?; ++ } ++ for (t, data) in &tables { ++ let start = out.len(); ++ out.extend_from_slice(data); ++ if t == b"head" { ++ out.get_mut(start + 8..start + 12)?.fill(0); ++ } ++ out.resize(out.len().next_multiple_of(4), 0); ++ } ++ if let Some(at) = head_at { ++ let adjustment = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out)); ++ out.get_mut(at + 8..at + 12)? ++ .copy_from_slice(&adjustment.to_be_bytes()); ++ } ++ Some(out) ++} ++ + #[cfg_attr(feature = "comemo", comemo::memoize)] + fn subset_font(font: Font, glyph_remapper: &GlyphRemapper) -> KrillaResult<(Font, Rect)> { + let mut bbox: Option = None; diff --git a/vendor/krilla/src/text/cid.rs b/vendor/krilla/src/text/cid.rs index 2c5212715..dbd4e3760 100644 --- a/vendor/krilla/src/text/cid.rs +++ b/vendor/krilla/src/text/cid.rs @@ -238,6 +238,8 @@ impl CIDFont { let num_glyphs = subsetted.num_glyphs(); let subsetted_data = subsetted.font_data().0; + // Outlives the stream, which may borrow it. + let with_os2; let font_stream = { let mut data = subsetted_data.as_ref().as_ref(); @@ -248,6 +250,14 @@ impl CIDFont { if let Some(cff) = subsetted_ref.data_for_tag(Cff::TAG) { data = cff.as_bytes(); + } else if let Some(os2) = self.font.font_ref().data_for_tag(OS2_TAG) { + // The subsetter writes no `OS/2`, so the font's embedding permissions + // (`fsType`) would not reach the PDF: a Preview & Print or Restricted font + // would look unrestricted to what reads it later. Keep the source's table. + if let Some(font) = with_table(data, OS2_TAG.to_be_bytes(), os2.as_bytes()) { + with_os2 = font; + data = &with_os2; + } } FilterStreamBuilder::new_from_binary_data(data).finish(&sc.serialize_settings()) @@ -433,6 +443,75 @@ pub(crate) fn base_font_name(font: &Font, data: &T) -> String { format!("{subset_tag}+{trimmed}") } +const OS2_TAG: skrifa::raw::types::Tag = skrifa::raw::types::Tag::new(b"OS/2"); + +/// The sfnt `font` with `table` added under `tag`, or `None` when `font` can't be read as one or +/// already has that table. The table directory is written in tag order with its binary-search +/// fields, every table 4-byte aligned with its checksum, and `head.checkSumAdjustment` +/// recomputed (OpenType, "Table directory" and "Calculating checksums"). +fn with_table(font: &[u8], tag: [u8; 4], table: &[u8]) -> Option> { + let be16 = |at: usize| Some(u16::from_be_bytes(font.get(at..at + 2)?.try_into().ok()?)); + let be32 = |at: usize| Some(u32::from_be_bytes(font.get(at..at + 4)?.try_into().ok()?)); + let count = usize::from(be16(4)?); + let mut tables: Vec<([u8; 4], &[u8])> = Vec::with_capacity(count + 1); + for k in 0..count { + let record = 12 + 16 * k; + let t: [u8; 4] = font.get(record..record + 4)?.try_into().ok()?; + let (offset, length) = (be32(record + 8)? as usize, be32(record + 12)? as usize); + tables.push((t, font.get(offset..offset.checked_add(length)?)?)); + } + if tables.iter().any(|(t, _)| *t == tag) { + return None; + } + tables.push((tag, table)); + tables.sort_by_key(|(t, _)| *t); + let n = u16::try_from(tables.len()).ok()?; + let entry_selector = 15 - n.leading_zeros() as u16; + let search_range = (1u16 << entry_selector).checked_mul(16)?; + let range_shift = n.checked_mul(16)?.checked_sub(search_range)?; + let checksum = |data: &[u8]| { + data.chunks(4).fold(0u32, |sum, c| { + let mut word = [0u8; 4]; + word[..c.len()].copy_from_slice(c); + sum.wrapping_add(u32::from_be_bytes(word)) + }) + }; + let mut out = Vec::with_capacity(font.len() + table.len() + 16 + 3); + out.extend_from_slice(font.get(0..4)?); + for v in [n, search_range, entry_selector, range_shift] { + out.extend_from_slice(&v.to_be_bytes()); + } + let mut offset = 12 + 16 * tables.len(); + let mut head_at = None; + for (t, data) in &tables { + let mut data = data.to_vec(); + if t == b"head" { + // checkSumAdjustment is zero while the checksums are taken + data.get_mut(8..12)?.fill(0); + head_at = Some(offset); + } + out.extend_from_slice(t); + out.extend_from_slice(&checksum(&data).to_be_bytes()); + out.extend_from_slice(&u32::try_from(offset).ok()?.to_be_bytes()); + out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_be_bytes()); + offset = offset.checked_add(data.len().next_multiple_of(4))?; + } + for (t, data) in &tables { + let start = out.len(); + out.extend_from_slice(data); + if t == b"head" { + out.get_mut(start + 8..start + 12)?.fill(0); + } + out.resize(out.len().next_multiple_of(4), 0); + } + if let Some(at) = head_at { + let adjustment = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out)); + out.get_mut(at + 8..at + 12)? + .copy_from_slice(&adjustment.to_be_bytes()); + } + Some(out) +} + #[cfg_attr(feature = "comemo", comemo::memoize)] fn subset_font(font: Font, glyph_remapper: &GlyphRemapper) -> KrillaResult<(Font, Rect)> { let mut bbox: Option = None;