Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions scripts/verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function writeOptHF(w, hf) {

/**
* Build a v10 snapshot.
* opts: { pagination, header, footer, fontBytes?, fontFamily?, chinese? }
* opts: { pagination, header, footer, fontBytes?, fontFamily?, chinese?, text? }
*/
function buildSnapshot(opts = {}) {
const w = new Bin();
Expand Down Expand Up @@ -99,7 +99,7 @@ function buildSnapshot(opts = {}) {
w.u32(0);

// nodes
const text = opts.chinese ? '你好,PDF!中文测试。' : 'Hello, PDF!';
const text = opts.text ?? (opts.chinese ? '你好,PDF!中文测试。' : 'Hello, PDF!');
const tlen = w.utf8Len(text);
w.u32(3); // nodeCount

Expand Down Expand Up @@ -270,6 +270,30 @@ check('has ExtGState resource dictionary', latin4.includes('/ExtGState <<'));
check('has opacity object', latin4.includes('/Type /ExtGState /ca 0.5 /CA 0.5'));
check('content stream applies gs operator', latin4.includes('/GS500 gs'));

// ---- Test 5: composite glyph dependencies keep Identity CIDs aligned ----
console.log('Test 5: composite glyph dependencies keep Identity CIDs aligned');
const compositeFontPath = path.join(root, 'assets/symbol-fallback.ttf');
if (existsSync(compositeFontPath)) {
const compositeFontBytes = readFileSync(compositeFontPath);
const snap5 = buildSnapshot({
pagination: true,
fontBytes: compositeFontBytes,
fontFamily: 'SymbolFallback',
text: '\u00E1', // aacute is a composite glyph in the fixture font
});
const pdf5 = render(snap5);
const latin5 = Buffer.from(pdf5).toString('latin1');
// .notdef, "a", and "acute" precede aacute in this subset, so the
// Identity-mapped content CID and ToUnicode source must both be GID 3.
check('content CID includes composite component slots', latin5.includes('<0003> Tj'));
check(
'ToUnicode uses the same composite CID',
latin5.includes('<0003> <00E1>'),
);
} else {
console.log(' SKIP: symbol fallback font not found at', compositeFontPath);
}

console.log('');
if (failures === 0) {
console.log('ALL PASS');
Expand Down
6 changes: 3 additions & 3 deletions src/wasm-base64.ts

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions wasm/src/font.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,12 @@ impl FontCtx {
}
used.sort_unstable();
used.dedup();
// Keep this list identical to the final subset font's glyph order.
// Composite outlines can pull in component glyphs that were not
// shaped directly; omitting them shifts every following Identity GID.
if let Ok(subset_glyphs) = cf.ttf.subset_glyphs(&used) {
used = subset_glyphs;
}
*cf.used_gids.borrow_mut() = used.clone();
let map: HashMap<u16, u16> = used
.into_iter()
Expand Down Expand Up @@ -475,3 +481,53 @@ pub fn encode_cid(cf: &CidFont, text: &str) -> (Vec<u8>, u32) {
}
(bytes, width)
}

#[cfg(test)]
mod tests {
use super::FontCtx;
use crate::snapshot::FontResource;
use crate::ttf::TtfFont;
use std::fs;
use std::path::PathBuf;

fn symbol_fallback_resource() -> FontResource {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("assets")
.join("symbol-fallback.ttf");
FontResource {
family: "SymbolFallback".into(),
style: 0,
weight: 400,
icon_font: false,
bytes: fs::read(path).expect("read symbol-fallback.ttf"),
}
}

#[test]
fn subset_map_includes_composite_glyph_dependencies() {
let resources = [symbol_fallback_resource()];
let fontctx = FontCtx::build(&resources).expect("build font context");
let codepoint = '\u{00E1}'; // aacute: a composite outline in the fixture font
let shaped = fontctx.shape(0, &codepoint.to_string(), true);
let old_gid = shaped[0].old_gid;
let directly_used = fontctx.cid[0].used_gids.borrow().clone();

fontctx.prepare_subset_maps();

let subset_glyphs = fontctx.cid[0].used_gids.borrow().clone();
assert!(
subset_glyphs.len() > directly_used.len() + 1,
"expected .notdef and composite component glyphs in the subset"
);
let expected_gid = subset_glyphs
.iter()
.position(|&gid| gid == old_gid)
.expect("composite glyph retained") as u16;
assert_eq!(fontctx.cid[0].subset_gid(old_gid), expected_gid);

let subset_bytes = fontctx.cid[0].ttf.embed_bytes(&subset_glyphs);
let subset = TtfFont::parse(&subset_bytes).expect("parse subset font");
assert_eq!(subset.gid_for(codepoint as u32), expected_gid);
}
}
15 changes: 13 additions & 2 deletions wasm/src/ttf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,14 +486,25 @@ impl TtfFont {
Ok(out)
}

fn subset_bytes(&self, used_gids: &[u16]) -> Result<Vec<u8>, String> {
/// Return the exact glyph set and order that `subset_bytes` will emit.
///
/// Composite glyphs may reference component glyphs that were not shaped
/// directly. Those components still occupy GIDs in the subset font. Since
/// the PDF uses `/CIDToGIDMap /Identity`, the content-stream map must use
/// this same transitive closure to keep CIDs aligned with subset GIDs.
pub(crate) fn subset_glyphs(&self, used_gids: &[u16]) -> Result<Vec<u16>, String> {
let loca = self.loca_offsets()?;
let mut keep = BTreeSet::new();
keep.insert(0);
for &gid in used_gids {
self.collect_composite_glyphs(gid, &loca, &mut keep)?;
}
let keep: Vec<u16> = keep.into_iter().collect();
Ok(keep.into_iter().collect())
}

fn subset_bytes(&self, used_gids: &[u16]) -> Result<Vec<u8>, String> {
let keep = self.subset_glyphs(used_gids)?;
let loca = self.loca_offsets()?;
let old_to_new: HashMap<u16, u16> = keep
.iter()
.enumerate()
Expand Down