krilla: TrueType subsets keep the font's OS/2 table (embedding permissions)

* krilla: TrueType subsets keep the font's OS/2 table (embedding permissions)

The subsetter writes only the tables it subsets, 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 (designcraft#1781).

Patch 18 adds the source font's OS/2 table, byte for byte, to a
TrueType subset that lacks one, rewriting the table directory with its
checksums and head.checkSumAdjustment. CFF programs are still embedded
bare. Ledger, patch file and SOURCE.json updated; craft-pdf's new
font_tables test checks the table, its fsType and the font's checksums.

* krilla: the OS/2 test checks the embedded program is a subset

Its sfnt and re-read checks also pass for the source font embedded whole. A subset has fewer glyphs than the fixture's 11, and a shorter glyf.

---------

Co-authored-by: Hamza <mail.hamzanaveed@gmail.com>
This commit is contained in:
Hamza NaveedandHamza authored and GitHub committed 2026-11-12 10:36:53 -05:00
1 parent 5a37e3e383
commit 9b7b748fbb
5 files changed
+311 -1

No files matched your search

+110
View File
@@ -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<u8> {
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<Vec<u8>> {
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::<Stream<'_>>(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::<Vec<_>>()
);
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());
}
+17
View File
@@ -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.
+1 -1
View File
@@ -332,7 +332,7 @@
{
"path": "src/text/cid.rs",
"upstream_sha256": "5332282160b75e2a24345b546ead219476768829bef881562f5658dd50ce97f2",
"sha256": "5332282160b75e2a24345b546ead219476768829bef881562f5658dd50ce97f2"
"sha256": "4bb482edddaf1cac916cb110e8139c90c53582f0c1c7afc6fa1cf6b037ccae5e"
},
{
"path": "src/text/font.rs",
+104
View File
@@ -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<T: Hash>(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<Vec<u8>> {
+ 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<Rect> = None;
+79
View File
@@ -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<T: Hash>(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<Vec<u8>> {
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<Rect> = None;