fix(fonts): keep a subset Type 0 font for glyphs /W leaves to /DW (part of #1388) (#2153)

This commit is contained in:
Marco Arnulfo
2026-10-15 02:47:44 -04:00
committed by GitHub
parent c1146d0d8d
commit c3e9609bd1
2 changed files with 113 additions and 1 deletions
+68
View File
@@ -1405,6 +1405,74 @@ fn a_subset_without_a_space_glyph_keeps_its_font_when_editing_words() {
}
}
/// A Chrome (Skia) style subset: Type0/Identity-H over a TrueType program, CID = glyph id.
/// `/W` lists most used glyphs, writes 0 for some it dropped, and leaves `a` and `y` to `/DW`;
/// `z` has a ToUnicode entry but no glyph in the program (its CID is past the last glyph).
fn skia_subset_page() -> Document {
// Inter's glyph ids: P 375, o 790, l 736, a 507, n 773, d 597, G 141, e 614, r 852, m 766, y 998, space 1777.
let mut doc = text_page(
"BT /F3 12 Tf 72 700 Td <0177031602E001FB03050255> Tj ET \
BT /F3 12 Tf 72 650 Td <008D0266035402FE01FB030503E606F101FB0305025506F102FE031603540266> Tj ET",
);
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
let unicode = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <0000> <FFFF> endcodespacerange 13 beginbfchar <0177> <0050> <0316> <006F> <02E0> <006C> \
<01FB> <0061> <0305> <006E> <0255> <0064> <008D> <0047> <0266> <0065> <0354> <0072> <02FE> <006D> <03E6> <0079> \
<06F1> <0020> <1000> <007A> endbfchar",
)));
let widths: Vec<Object> = [(0x8D, 746), (0x177, 639), (0x255, 612), (0x266, 583), (0x2E0, 242), (0x2FE, 876), (0x305, 591), (0x316, 600)]
.into_iter()
.chain([(0x354, 376), (0x355, 0), (0x356, 0), (0x6F1, 281)])
.flat_map(|(cid, width)| [Object::Int(cid), Object::Array(vec![Object::Int(width)])])
.collect();
let descriptor = Dict::from_iter([
(b"Type".to_vec(), Object::name("FontDescriptor")),
(b"FontName".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"Flags".to_vec(), Object::Int(32)),
(b"FontFile2".to_vec(), Object::Ref(program)),
]);
let cid_font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("CIDFontType2")),
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"CIDToGIDMap".to_vec(), Object::name("Identity")),
(b"DW".to_vec(), Object::Int(562)),
(b"W".to_vec(), Object::Array(widths)),
(b"FontDescriptor".to_vec(), Object::Dict(descriptor)),
]);
let font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type0")),
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"Encoding".to_vec(), Object::name("Identity-H")),
(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid_font)])),
(b"ToUnicode".to_vec(), Object::Ref(unicode)),
]);
let p = pdfcraft_model::pages(&doc)[0].obj;
let fonts = Dict::from_iter([(b"F3".to_vec(), Object::Dict(font))]);
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))])))).unwrap();
doc
}
#[test]
fn a_subset_keeps_its_font_for_glyphs_left_to_the_default_width() {
let doc = skia_subset_page();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Poland", "Germany and more"]);
// Deleting words only, and replacing a word with one drawn elsewhere on the page (`y` and `a`
// have no /W entry): every glyph is in the subset, so the font stays.
for (block, text) in [(1, "Germany"), (0, "Germany")] {
let mut doc = doc.clone();
assert_eq!(text::replace_block(&mut doc, 0, block, text).unwrap().substituted, None, "{text}");
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines.iter().find(|l| l.text == text).unwrap().font, "F3");
}
// `z` maps to a CID the program has no glyph for: that still needs another font.
let mut doc = doc.clone();
assert!(text::replace_block(&mut doc, 0, 0, "Graz").unwrap().substituted.is_some());
}
#[test]
fn spaces_in_another_subset_do_not_split_a_line() {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj /F2 12 Tf ( ) Tj /F1 12 Tf (world) Tj ET");
+45 -1
View File
@@ -61,6 +61,9 @@ pub struct Metrics {
code_len: usize,
/// Valid single-character explicit mappings, preindexed once for large full CJK fonts.
explicit_reverse: HashMap<char, u32>,
/// Subset CIDFontType2 only: one bit per CID (below 65 536) whose glyph has an outline in the
/// embedded program. See [`cids_with_outlines`].
outlined_cids: Vec<u64>,
}
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
@@ -129,6 +132,38 @@ pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option<Vec<u8>> {
None
}
/// The CIDs (below 65 536) of a Type 0 font over an embedded TrueType program whose glyph has
/// outline data, through `/CIDToGIDMap`; empty when the program or the map can't be read.
/// Subsetters keep glyph ids and empty the glyphs they drop, so an empty glyph is a missing one.
fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
use skrifa::raw::TableProvider as _;
let Some(descendant) = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref())
else {
return Vec::new();
};
let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() };
let program = doc.resolve(&program);
let Object::Stream(stream) = program.as_ref() else { return Vec::new() };
let Ok(bytes) = stream.decoded_within(16 << 20) else { return Vec::new() };
let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() };
let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start);
match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() {
None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))),
Some(Object::Stream(map)) => {
let Ok(map) = map.decoded_within(1 << 17) else { return Vec::new() };
bit_set_below_64k(
map.as_chunks::<2>()
.0
.iter()
.enumerate()
.filter(|(_, gid)| outlined(u32::from(u16::from_be_bytes(**gid))))
.map(|(cid, _)| cid as u32),
)
}
Some(_) => Vec::new(),
}
}
/// Replace an existing cmap directory entry with a bounded Unicode format-12 table.
/// Checked ranges prevent damaged sfnt offsets from indexing outside the source program.
fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap<u32, u32>) -> Option<()> {
@@ -538,6 +573,7 @@ impl Metrics {
visual_order: false,
code_len: 1,
explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(),
outlined_cids: Vec::new(),
}
}
@@ -558,6 +594,9 @@ impl Metrics {
m.bold |= bold;
m.italic |= italic;
m.base_font = base;
if m.composite && m.subset {
m.outlined_cids = cids_with_outlines(doc, font);
}
m.code_len = match &m.codes {
Codes::One => 1,
Codes::Two => 2,
@@ -752,7 +791,12 @@ impl Metrics {
fn has_glyph(&self, code: u32) -> bool {
if self.composite {
let cid = self.cid(code);
return self.cid_widths.contains_key(&cid) || self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b) || !self.subset;
// A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for
// those, only the subset's program says whether the glyph is there.
return self.cid_widths.contains_key(&cid)
|| self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b)
|| !self.subset
|| self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1);
}
match code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) {
Some(w) => *w > 0.0 || code == 32,