diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 272a15ec0b..0e8dba5afa 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -10,11 +10,11 @@ //! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted. //! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`). -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; -use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream}; use pdfcraft_fonts::pdf::Metrics; use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from}; @@ -158,6 +158,270 @@ pub fn register_text_font(doc: &mut Document, page: usize, bytes: &[u8]) -> Resu Ok(name) } +fn page_content_bytes_for_subset(doc: &Document, page: &pdfcraft_model::Page) -> Vec { + let Some(c) = page.dict.get(b"Contents") else { return Vec::new() }; + match doc.resolve(c).as_ref() { + Object::Stream(s) => s.decoded_within(16 << 20).unwrap_or_default(), + Object::Array(a) => a + .iter() + .flat_map(|x| match doc.resolve(x).as_ref() { + Object::Stream(s) => s.decoded_within(16 << 20).unwrap_or_default(), + _ => Vec::new(), + }) + .collect(), + _ => Vec::new(), + } +} + +/// Subset any added TrueType fonts in `doc` (marked with `/PCFullFont`) to only the glyphs used, +/// update their BaseFont/FontName with an `ABCDEF+` subset prefix, and remove `/PCFullFont`. +pub fn subset_document_fonts(doc: &mut Document) -> Result<(), EditError> { + let mut added_programs: Vec = Vec::new(); + for (r, obj) in doc.scan_objects() { + if let Object::Stream(s) = obj.as_ref() + && s.dict.get(b"PCFullFont").is_some() + { + added_programs.push(r); + } + } + if added_programs.is_empty() { + return Ok(()); + } + + let mut desc_for_program: HashMap = HashMap::new(); + let mut cid_for_desc: HashMap = HashMap::new(); + let mut type0_for_cid: HashMap> = HashMap::new(); + + for (r, obj) in doc.scan_objects() { + if let Object::Dict(d) = obj.as_ref() { + if d.name(b"Type") == Some(b"FontDescriptor") { + if let Some(Object::Ref(p_ref)) = d.get(b"FontFile2").or_else(|| d.get(b"FontFile3")) + && added_programs.contains(p_ref) + { + desc_for_program.insert(*p_ref, r); + } + } else if d.name(b"Type") == Some(b"Font") { + if d.name(b"Subtype") == Some(b"CIDFontType2") || d.name(b"Subtype") == Some(b"CIDFontType0") { + if let Some(Object::Ref(d_ref)) = d.get(b"FontDescriptor") { + cid_for_desc.insert(*d_ref, r); + } + } else if d.name(b"Subtype") == Some(b"Type0") { + let desc_opt = d.get(b"DescendantFonts").map(|o| doc.resolve(o)); + if let Some(desc_arc) = desc_opt + && let Object::Array(descendants) = desc_arc.as_ref() + && let Some(Object::Ref(c_ref)) = descendants.first() + { + type0_for_cid.entry(*c_ref).or_default().push(r); + } + } + } + } + } + + let mut type0_to_program: HashMap = HashMap::new(); + for (&prog, &desc) in &desc_for_program { + if let Some(&cid) = cid_for_desc.get(&desc) + && let Some(type0s) = type0_for_cid.get(&cid) + { + for &t0 in type0s { + type0_to_program.insert(t0, prog); + } + } + } + + let pages = pdfcraft_model::pages(doc); + let mut used_cids_by_program: HashMap> = HashMap::new(); + + for page in &pages { + let Some(res) = page.dict.get(b"Resources").and_then(|r| doc.dict(r)) else { continue }; + let mut font_res_to_program: HashMap, ObjRef> = HashMap::new(); + if let Some(fonts) = res.get(b"Font").and_then(|f| doc.dict(f)) { + for (name, val) in fonts.iter() { + if let Object::Ref(r) = val + && let Some(&prog) = type0_to_program.get(r) + { + font_res_to_program.insert(name.clone(), prog); + } + } + } + if font_res_to_program.is_empty() { + continue; + } + + let content = page_content_bytes_for_subset(doc, page); + let parsed = parse(&content); + let mut current_font: Option> = None; + for op in parsed.ops { + if op.is("Tf") { + current_font = op.operands.first().and_then(|o| o.as_name()).map(|n| n.to_vec()); + } else if let Some(cur) = current_font.as_ref() + && let Some(&prog) = font_res_to_program.get(cur) + { + let cids = used_cids_by_program.entry(prog).or_default(); + if op.is("Tj") || op.is("'") { + if let Some(s) = op.operands.first().and_then(|o| o.as_string()) { + for chunk in s.bytes.as_chunks::<2>().0 { + cids.insert(u16::from_be_bytes(*chunk)); + } + } + } else if op.is("\"") { + if let Some(s) = op.operands.get(2).and_then(|o| o.as_string()) { + for chunk in s.bytes.as_chunks::<2>().0 { + cids.insert(u16::from_be_bytes(*chunk)); + } + } + } else if op.is("TJ") + && let Some(arr) = op.operands.first().and_then(|o| o.as_array()) + { + for item in arr { + if let Some(s) = item.as_string() { + for chunk in s.bytes.as_chunks::<2>().0 { + cids.insert(u16::from_be_bytes(*chunk)); + } + } + } + } + } + } + } + + for prog_ref in added_programs { + let Some(&desc_ref) = desc_for_program.get(&prog_ref) else { continue }; + let Some(&cid_ref) = cid_for_desc.get(&desc_ref) else { continue }; + let empty_set = BTreeSet::new(); + let used_cids = used_cids_by_program.get(&prog_ref).unwrap_or(&empty_set); + + let Some(cid_dict) = doc.dict(&Object::Ref(cid_ref)) else { continue }; + let cid_to_gid_obj = cid_dict.get(b"CIDToGIDMap").cloned(); + let mut map_bytes: Option<(ObjRef, Vec)> = None; + let mut used_gids = BTreeSet::new(); + used_gids.insert(0u16); + + if let Some(Object::Ref(map_ref)) = cid_to_gid_obj { + if let Object::Stream(map_stream) = doc.get(map_ref).as_ref() + && let Ok(bytes) = map_stream.decoded_within(1 << 17) + { + for &cid in used_cids { + let offset = usize::from(cid).saturating_mul(2); + if let Some(chunk) = bytes.get(offset..offset.saturating_add(2)) + && chunk.len() == 2 + { + let gid = u16::from_be_bytes([chunk[0], chunk[1]]); + used_gids.insert(gid); + } + } + map_bytes = Some((map_ref, bytes)); + } + } else { + for &cid in used_cids { + used_gids.insert(cid); + } + } + + let Object::Stream(prog_stream) = doc.get(prog_ref).as_ref().clone() else { continue }; + let Ok(font_bytes) = prog_stream.decoded_within(16 << 20) else { continue }; + + let prefix = pdfcraft_fonts::embedding::subset_tag(u64::from(prog_ref.num)); + let mut desc_dict = doc.dict(&Object::Ref(desc_ref)).unwrap_or_default(); + let orig_name = + desc_dict.name(b"FontName").or_else(|| cid_dict.name(b"BaseFont")).and_then(|n| std::str::from_utf8(n).ok()).unwrap_or("Font"); + let clean_name = orig_name.rsplit('+').next().unwrap_or(orig_name); + let subset_name = format!("{prefix}{clean_name}"); + + let used_gids_vec: Vec = used_gids.into_iter().collect(); + + if let Ok((subset_bytes, old_to_new)) = pdfcraft_fonts::embedding::subset_truetype(&font_bytes, &used_gids_vec, &subset_name) { + let mut new_prog_dict = prog_stream.dict.clone(); + new_prog_dict.remove(b"PCFullFont"); + new_prog_dict.set(b"Length1".to_vec(), Object::Int(subset_bytes.len() as i64)); + doc.set(prog_ref, Object::Stream(Stream::flate(new_prog_dict, &subset_bytes))); + + if let Some((map_ref, mut m_bytes)) = map_bytes { + let max_cid = used_cids.iter().copied().next_back().unwrap_or(0); + let needed_len = usize::from(max_cid).saturating_add(1).saturating_mul(2); + if m_bytes.len() > needed_len { + m_bytes.truncate(needed_len); + } + for i in 0..m_bytes.len() / 2 { + let s_idx = i.saturating_mul(2); + if let Some(chunk) = m_bytes.get(s_idx..s_idx.saturating_add(2)) { + let old_gid = u16::from_be_bytes([chunk[0], chunk[1]]); + let new_gid = old_to_new.get(&old_gid).copied().unwrap_or(0); + if let Some(target) = m_bytes.get_mut(s_idx..s_idx.saturating_add(2)) { + target.copy_from_slice(&new_gid.to_be_bytes()); + } + } + } + doc.set(map_ref, Object::Stream(Stream::flate(Dict::new(), &m_bytes))); + } + } else { + let mut new_prog_dict = prog_stream.dict.clone(); + new_prog_dict.remove(b"PCFullFont"); + doc.set(prog_ref, Object::Stream(Stream::flate(new_prog_dict, &font_bytes))); + } + + desc_dict.set(b"FontName".to_vec(), Object::name(&subset_name)); + doc.set(desc_ref, Object::Dict(desc_dict)); + + let mut new_cid_dict = cid_dict.clone(); + new_cid_dict.set(b"BaseFont".to_vec(), Object::name(&subset_name)); + + let first_t0 = type0_for_cid.get(&cid_ref).and_then(|t0s| t0s.first()).and_then(|r| doc.dict(&Object::Ref(*r))); + let m_opt = first_t0.as_ref().map(|d| Metrics::from_dict(doc, d)); + + if let Some(ref m) = m_opt { + let mut w_array = Vec::new(); + for &cid in used_cids { + let w = m.width(u32::from(cid)) * 1000.0; + w_array.push(Object::Int(i64::from(cid))); + w_array.push(Object::Array(vec![Object::Real(w)])); + } + if !w_array.is_empty() { + new_cid_dict.set(b"W".to_vec(), Object::Array(w_array)); + } + + let mut cmap = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapName /PCUnicode def\n/CMapType 2 def\n1 begincodespacerange\n<0000> \nendcodespacerange\n", + ); + let used_cids_vec: Vec = used_cids.iter().copied().collect(); + for chunk in used_cids_vec.chunks(100) { + use std::fmt::Write as _; + let _ = writeln!(cmap, "{} beginbfchar", chunk.len()); + for &cid in chunk { + let text = m.decode(&cid.to_be_bytes()); + let hex: String = if text.is_empty() { format!("{cid:04X}") } else { text.encode_utf16().map(|u| format!("{u:04X}")).collect() }; + let _ = writeln!(cmap, "<{cid:04X}> <{hex}>"); + } + cmap.push_str("endbfchar\n"); + } + cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + + if let Some(type0s) = type0_for_cid.get(&cid_ref) { + for &t0_ref in type0s { + if let Some(t0_dict) = doc.dict(&Object::Ref(t0_ref)) + && let Some(Object::Ref(to_uni_ref)) = t0_dict.get(b"ToUnicode") + { + doc.set(*to_uni_ref, Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); + } + } + } + } + + doc.set(cid_ref, Object::Dict(new_cid_dict)); + + if let Some(type0s) = type0_for_cid.get(&cid_ref) { + for &t0_ref in type0s { + if let Some(mut t0_dict) = doc.dict(&Object::Ref(t0_ref)) { + t0_dict.set(b"BaseFont".to_vec(), Object::name(&subset_name)); + doc.set(t0_ref, Object::Dict(t0_dict)); + } + } + } + } + + Ok(()) +} + /// Read-only draft bounds, measured with the same PDF rewrite used by Apply/Save. #[derive(Clone, Debug, PartialEq)] pub struct TextEditLayout { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a8ace8b5d4..21175bdb4d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4484,12 +4484,14 @@ impl Session { // file so that revision stays in the chain. A full rewrite (redaction, new encryption, // a reconstructed file) still writes the current graph on its own. let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let mut cos = editor.cos.clone(); + pdfcraft_edit::text::subset_document_fonts(&mut cos)?; guard(|| { if on_saved || rewrite { - write_incremental(&editor.cos, &opts) + write_incremental(&cos, &opts) } else { let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&editor.cos)?; + base.overlay_changes_from(&cos)?; write_incremental(&base, &opts) } }) @@ -4506,7 +4508,9 @@ impl Session { } let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - guard(|| write_full(&editor.cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + let mut cos = editor.cos.clone(); + pdfcraft_edit::text::subset_document_fonts(&mut cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this diff --git a/crates/engine/tests/it/document_fonts.rs b/crates/engine/tests/it/document_fonts.rs index e4212f4a3a..bf9415ce91 100644 --- a/crates/engine/tests/it/document_fonts.rs +++ b/crates/engine/tests/it/document_fonts.rs @@ -96,3 +96,50 @@ fn contextual_arabic_marks_ligatures_and_mixed_bidi_keep_logical_text_after_reop } exercise(font.bytes.to_vec(), "سَلَام 123", "NotoSansArabic-Regular"); } + +#[test] +fn added_font_is_subset_to_used_glyphs_on_save() { + let font_bytes = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf")).unwrap(); + let font_len = font_bytes.len(); + assert!(font_len > 300_000, "source font is full sized"); + + let mut s = Session::new(); + let id = s.open("source.pdf", None, document(), None).unwrap(); + s.apply(id, Edit::RegisterTextFont { page: 0, bytes: Arc::new(font_bytes) }).unwrap(); + let resource = s.get(id).unwrap().text_editor_fonts(0).into_iter().find(|f| f.face == "Inter-Regular").unwrap().resource; + let style = BlockStyle { resource: Some(resource), width: Some(240.0), ..Default::default() }; + let text = "Three words here"; + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: text.into(), style }).unwrap(); + + let saved = s.save_bytes(id).unwrap(); + assert!(saved.len() < 50_000, "saved PDF with 3 words must be subset and small, was {} bytes", saved.len()); + + let saved_doc = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let mut subset_found = false; + for (_, obj) in saved_doc.scan_objects() { + if let pdfcraft_cos::Object::Dict(d) = obj.as_ref() + && d.name(b"Type") == Some(b"Font") + && d.name(b"Subtype") == Some(b"Type0") + && let Some(base) = d.name(b"BaseFont") + { + let base_str = String::from_utf8_lossy(base); + if base_str.ends_with("Inter-Regular") { + assert!(base_str.len() >= 7 && base_str.as_bytes()[6] == b'+', "font base name {base_str} must have subset prefix"); + subset_found = true; + } + } + if let pdfcraft_cos::Object::Stream(stream) = obj.as_ref() + && stream.dict.get(b"PCFullFont").is_some() + { + panic!("saved PDF must not retain PCFullFont flag"); + } + } + assert!(subset_found, "Type0 subset font must be present in saved document"); + + // Also verify reopening and rendering matches: + let re = s.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(s.get(re).unwrap().text_blocks(0)[0].editor_text, text); + let re_pixels = pixels(s.get(re).unwrap().display.clone()); + let orig_pixels = pixels(s.get(id).unwrap().display.clone()); + assert_eq!(re_pixels, orig_pixels, "reopened subset font renders identically to original"); +} diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index ea0f747854..866ab7c0b1 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -1,7 +1,7 @@ //! Unicode embedding of an explicitly supplied, embeddable TrueType face. Font files are //! document inputs or the optional craft-fonts build input, never system-interface fallbacks. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::fmt::Write as _; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -338,6 +338,438 @@ fn same_resource(doc: &Document, fonts: &Dict, name: &str, programs: &mut Progra .map(|w| w.resource) } +/// Generate a 6-character subset prefix tag for a font name, e.g. "ABCDEF+". +pub fn subset_tag(seed: u64) -> String { + let mut tag = String::with_capacity(7); + let mut s = seed; + for _ in 0..6 { + let b = b'A'.saturating_add((s % 26) as u8); + tag.push(b as char); + s /= 26; + } + tag.push('+'); + tag +} + +fn calc_table_checksum(data: &[u8]) -> u32 { + let mut sum = 0u32; + for chunk in data.chunks(4) { + let mut b = [0u8; 4]; + if let Some(target) = b.get_mut(..chunk.len()) { + target.copy_from_slice(chunk); + } + sum = sum.wrapping_add(u32::from_be_bytes(b)); + } + sum +} + +fn read_tables(bytes: &[u8]) -> Option> { + let count_bytes: [u8; 2] = bytes.get(4..6)?.try_into().ok()?; + let num_tables = usize::from(u16::from_be_bytes(count_bytes)); + if num_tables > 128 { + return None; + } + let mut map = HashMap::new(); + for i in 0..num_tables { + let entry = 12usize.checked_add(i.checked_mul(16)?)?; + let tag: [u8; 4] = bytes.get(entry..entry.checked_add(4)?)?.try_into().ok()?; + let off_bytes: [u8; 4] = bytes.get(entry.checked_add(8)?..entry.checked_add(12)?)?.try_into().ok()?; + let offset = usize::try_from(u32::from_be_bytes(off_bytes)).ok()?; + let len_bytes: [u8; 4] = bytes.get(entry.checked_add(12)?..entry.checked_add(16)?)?.try_into().ok()?; + let length = usize::try_from(u32::from_be_bytes(len_bytes)).ok()?; + let table_data = bytes.get(offset..offset.checked_add(length)?)?; + map.insert(tag, table_data); + } + Some(map) +} + +fn glyph_slice<'a>(loca: &'a [u8], glyf: &'a [u8], is_long: bool, gid: u16) -> Option<&'a [u8]> { + let (start, end) = if is_long { + let s_idx = usize::from(gid).checked_mul(4)?; + let e_idx = s_idx.checked_add(4)?; + let start_bytes: [u8; 4] = loca.get(s_idx..e_idx)?.try_into().ok()?; + let end_bytes: [u8; 4] = loca.get(e_idx..e_idx.checked_add(4)?)?.try_into().ok()?; + (usize::try_from(u32::from_be_bytes(start_bytes)).ok()?, usize::try_from(u32::from_be_bytes(end_bytes)).ok()?) + } else { + let s_idx = usize::from(gid).checked_mul(2)?; + let e_idx = s_idx.checked_add(2)?; + let start_bytes: [u8; 2] = loca.get(s_idx..e_idx)?.try_into().ok()?; + let end_bytes: [u8; 2] = loca.get(e_idx..e_idx.checked_add(2)?)?.try_into().ok()?; + (usize::from(u16::from_be_bytes(start_bytes)).checked_mul(2)?, usize::from(u16::from_be_bytes(end_bytes)).checked_mul(2)?) + }; + if start <= end && end <= glyf.len() { glyf.get(start..end) } else { None } +} + +fn composite_components(data: &[u8]) -> Vec { + let mut comps = Vec::new(); + if data.len() < 10 { + return comps; + } + let Some(header) = data.get(0..2) else { return comps }; + let num_contours = i16::from_be_bytes([header[0], header[1]]); + if num_contours != -1 { + return comps; + } + let mut offset = 10usize; + while let Some(flags_bytes) = data.get(offset..offset.saturating_add(2)) { + if flags_bytes.len() < 2 { + break; + } + let flags = u16::from_be_bytes([flags_bytes[0], flags_bytes[1]]); + let Some(gid_bytes) = data.get(offset.saturating_add(2)..offset.saturating_add(4)) else { break }; + if gid_bytes.len() < 2 { + break; + } + let gid = u16::from_be_bytes([gid_bytes[0], gid_bytes[1]]); + comps.push(gid); + let Some(mut next_offset) = offset.checked_add(4) else { break }; + if flags & 0x0001 != 0 { + next_offset = match next_offset.checked_add(4) { + Some(o) => o, + None => break, + }; + } else { + next_offset = match next_offset.checked_add(2) { + Some(o) => o, + None => break, + }; + } + if flags & 0x0008 != 0 { + next_offset = match next_offset.checked_add(2) { + Some(o) => o, + None => break, + }; + } else if flags & 0x0040 != 0 { + next_offset = match next_offset.checked_add(4) { + Some(o) => o, + None => break, + }; + } else if flags & 0x0080 != 0 { + next_offset = match next_offset.checked_add(8) { + Some(o) => o, + None => break, + }; + } + offset = next_offset; + if flags & 0x0020 == 0 { + break; + } + } + comps +} + +fn remap_composite_glyph(data: &[u8], old_to_new: &HashMap) -> Vec { + let mut out = data.to_vec(); + if out.len() < 10 { + return out; + } + let Some(header) = out.get(0..2) else { return out }; + let num_contours = i16::from_be_bytes([header[0], header[1]]); + if num_contours != -1 { + return out; + } + let mut offset = 10usize; + while let Some(flags_bytes) = out.get(offset..offset.saturating_add(2)) { + if flags_bytes.len() < 2 { + break; + } + let flags = u16::from_be_bytes([flags_bytes[0], flags_bytes[1]]); + let Some(gid_bytes) = out.get(offset.saturating_add(2)..offset.saturating_add(4)) else { break }; + if gid_bytes.len() < 2 { + break; + } + let old_gid = u16::from_be_bytes([gid_bytes[0], gid_bytes[1]]); + let new_gid = old_to_new.get(&old_gid).copied().unwrap_or(0); + if let Some(slot) = out.get_mut(offset.saturating_add(2)..offset.saturating_add(4)) { + slot.copy_from_slice(&new_gid.to_be_bytes()); + } + let Some(mut next_offset) = offset.checked_add(4) else { break }; + if flags & 0x0001 != 0 { + next_offset = match next_offset.checked_add(4) { + Some(o) => o, + None => break, + }; + } else { + next_offset = match next_offset.checked_add(2) { + Some(o) => o, + None => break, + }; + } + if flags & 0x0008 != 0 { + next_offset = match next_offset.checked_add(2) { + Some(o) => o, + None => break, + }; + } else if flags & 0x0040 != 0 { + next_offset = match next_offset.checked_add(4) { + Some(o) => o, + None => break, + }; + } else if flags & 0x0080 != 0 { + next_offset = match next_offset.checked_add(8) { + Some(o) => o, + None => break, + }; + } + offset = next_offset; + if flags & 0x0020 == 0 { + break; + } + } + out +} + +fn build_name_table(ps_name: &str) -> Vec { + let utf16_be: Vec = ps_name.encode_utf16().flat_map(u16::to_be_bytes).collect(); + let str_len = u16::try_from(utf16_be.len()).unwrap_or(0); + let mut out = Vec::new(); + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&18u16.to_be_bytes()); + out.extend_from_slice(&3u16.to_be_bytes()); + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&0x0409u16.to_be_bytes()); + out.extend_from_slice(&6u16.to_be_bytes()); + out.extend_from_slice(&str_len.to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&utf16_be); + out +} + +fn build_cmap_table(char_to_new_gid: &[(u32, u32)]) -> Vec { + let mut sorted = char_to_new_gid.to_vec(); + if sorted.is_empty() { + sorted.push((0, 0)); + } + sorted.sort_by_key(|&(c, _)| c); + sorted.dedup_by_key(|&mut (c, _)| c); + let n_groups = u32::try_from(sorted.len()).unwrap_or(1); + let subtable_len = 16u32.saturating_add(n_groups.saturating_mul(12)); + let mut out = Vec::new(); + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&3u16.to_be_bytes()); + out.extend_from_slice(&10u16.to_be_bytes()); + out.extend_from_slice(&12u32.to_be_bytes()); + out.extend_from_slice(&12u16.to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&subtable_len.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&n_groups.to_be_bytes()); + for &(c, gid) in &sorted { + out.extend_from_slice(&c.to_be_bytes()); + out.extend_from_slice(&c.to_be_bytes()); + out.extend_from_slice(&gid.to_be_bytes()); + } + out +} + +/// Subset a TrueType font to only the specified glyph IDs. +/// Returns the new sfnt font binary and a mapping from old GID to new GID. +pub fn subset_truetype(bytes: &[u8], used_gids: &[u16], postscript_name: &str) -> Result<(Vec, HashMap), String> { + validate(bytes)?; + let map = read_tables(bytes).ok_or("cannot parse font table directory")?; + let orig_glyf = map.get(b"glyf").copied().ok_or("the font has no TrueType glyf table")?; + let orig_loca = map.get(b"loca").copied().ok_or("missing loca table")?; + let orig_head = map.get(b"head").copied().ok_or("missing head table")?; + let orig_hhea = map.get(b"hhea").copied().ok_or("missing hhea table")?; + let orig_hmtx = map.get(b"hmtx").copied().ok_or("missing hmtx table")?; + let orig_maxp = map.get(b"maxp").copied().ok_or("missing maxp table")?; + + let index_fmt_bytes: [u8; 2] = orig_head.get(50..52).ok_or("invalid head table")?.try_into().map_err(|_| "invalid head table")?; + let is_long = i16::from_be_bytes(index_fmt_bytes) != 0; + + let mut needed_gids = BTreeSet::new(); + needed_gids.insert(0u16); + for &g in used_gids { + needed_gids.insert(g); + } + let mut queue: Vec = needed_gids.iter().copied().collect(); + while let Some(gid) = queue.pop() { + if let Some(g_data) = glyph_slice(orig_loca, orig_glyf, is_long, gid) { + for comp in composite_components(g_data) { + if needed_gids.insert(comp) { + queue.push(comp); + } + } + } + } + + let mut subset_glyphs = Vec::with_capacity(needed_gids.len()); + let mut old_to_new_gid = HashMap::new(); + for (new_gid, &old_gid) in needed_gids.iter().enumerate() { + let new_gid_u16 = u16::try_from(new_gid).map_err(|_| "too many glyphs in subset")?; + old_to_new_gid.insert(old_gid, new_gid_u16); + subset_glyphs.push(old_gid); + } + + let mut subset_glyf = Vec::new(); + let mut subset_loca = Vec::with_capacity((subset_glyphs.len().saturating_add(1)).saturating_mul(4)); + for &old_gid in &subset_glyphs { + let offset_u32 = u32::try_from(subset_glyf.len()).map_err(|_| "glyf table overflow")?; + subset_loca.extend_from_slice(&offset_u32.to_be_bytes()); + if let Some(g_data) = glyph_slice(orig_loca, orig_glyf, is_long, old_gid) + && !g_data.is_empty() + { + let remapped = remap_composite_glyph(g_data, &old_to_new_gid); + subset_glyf.extend_from_slice(&remapped); + if subset_glyf.len() % 2 != 0 { + subset_glyf.push(0); + } + } + } + let final_offset_u32 = u32::try_from(subset_glyf.len()).map_err(|_| "glyf table overflow")?; + subset_loca.extend_from_slice(&final_offset_u32.to_be_bytes()); + + let hmetrics_bytes: [u8; 2] = orig_hhea.get(34..36).ok_or("invalid hhea")?.try_into().map_err(|_| "invalid hhea")?; + let orig_num_hmetrics = usize::from(u16::from_be_bytes(hmetrics_bytes)); + let mut subset_hmtx = Vec::with_capacity(subset_glyphs.len().saturating_mul(4)); + for &old_gid in &subset_glyphs { + let old_idx = usize::from(old_gid); + let (adv, lsb) = if old_idx < orig_num_hmetrics { + let adv_bytes: [u8; 2] = orig_hmtx.get(old_idx * 4..old_idx * 4 + 2).unwrap_or(&[0, 0]).try_into().unwrap_or([0, 0]); + let lsb_bytes: [u8; 2] = orig_hmtx.get(old_idx * 4 + 2..old_idx * 4 + 4).unwrap_or(&[0, 0]).try_into().unwrap_or([0, 0]); + (u16::from_be_bytes(adv_bytes), i16::from_be_bytes(lsb_bytes)) + } else { + let last_adv_offset = orig_num_hmetrics.saturating_sub(1) * 4; + let adv_bytes: [u8; 2] = orig_hmtx.get(last_adv_offset..last_adv_offset + 2).unwrap_or(&[0, 0]).try_into().unwrap_or([0, 0]); + let lsb_offset = orig_num_hmetrics * 4 + (old_idx.saturating_sub(orig_num_hmetrics)) * 2; + let lsb_bytes: [u8; 2] = orig_hmtx.get(lsb_offset..lsb_offset + 2).unwrap_or(&[0, 0]).try_into().unwrap_or([0, 0]); + (u16::from_be_bytes(adv_bytes), i16::from_be_bytes(lsb_bytes)) + }; + subset_hmtx.extend_from_slice(&adv.to_be_bytes()); + subset_hmtx.extend_from_slice(&lsb.to_be_bytes()); + } + + let mut subset_hhea = orig_hhea.to_vec(); + if subset_hhea.len() >= 36 { + let num_metrics = u16::try_from(subset_glyphs.len()).map_err(|_| "too many glyphs")?; + if let Some(target) = subset_hhea.get_mut(34..36) { + target.copy_from_slice(&num_metrics.to_be_bytes()); + } + } + + let mut subset_maxp = orig_maxp.to_vec(); + if subset_maxp.len() >= 6 { + let num_g = u16::try_from(subset_glyphs.len()).map_err(|_| "too many glyphs")?; + if let Some(target) = subset_maxp.get_mut(4..6) { + target.copy_from_slice(&num_g.to_be_bytes()); + } + } + + let mut subset_head = orig_head.to_vec(); + if subset_head.len() >= 54 { + if let Some(target) = subset_head.get_mut(8..12) { + target.copy_from_slice(&[0, 0, 0, 0]); + } + if let Some(target) = subset_head.get_mut(50..52) { + target.copy_from_slice(&1i16.to_be_bytes()); + } + } + + let mut subset_post = vec![0u8; 32]; + if let Some(target) = subset_post.get_mut(0..4) { + target.copy_from_slice(&0x00030000u32.to_be_bytes()); + } + if let Some(orig_post) = map.get(b"post") + && orig_post.len() >= 16 + && let (Some(target), Some(src)) = (subset_post.get_mut(4..16), orig_post.get(4..16)) + { + target.copy_from_slice(src); + } + + let char_to_new: Vec<(u32, u32)> = FontRef::new(bytes) + .ok() + .map(|face| { + face.charmap().mappings().filter_map(|(cp, old)| old_to_new_gid.get(&(old.to_u32() as u16)).map(|&new| (cp, u32::from(new)))).collect() + }) + .unwrap_or_default(); + + let subset_name = build_name_table(postscript_name); + let subset_cmap = build_cmap_table(&char_to_new); + + let mut tables: Vec<([u8; 4], Vec)> = vec![ + (*b"cmap", subset_cmap), + (*b"glyf", subset_glyf), + (*b"head", subset_head), + (*b"hhea", subset_hhea), + (*b"hmtx", subset_hmtx), + (*b"loca", subset_loca), + (*b"maxp", subset_maxp), + (*b"name", subset_name), + ]; + if let Some(os2) = map.get(b"OS/2") { + tables.push((*b"OS/2", os2.to_vec())); + } + tables.push((*b"post", subset_post)); + + tables.sort_by_key(|t| t.0); + + let num_tables = u16::try_from(tables.len()).map_err(|_| "too many tables")?; + let mut max_pow2 = 1u16; + let mut entry_selector = 0u16; + while max_pow2.saturating_mul(2) <= num_tables { + max_pow2 = max_pow2.saturating_mul(2); + entry_selector = entry_selector.saturating_add(1); + } + let search_range = max_pow2.saturating_mul(16); + let range_shift = num_tables.saturating_mul(16).saturating_sub(search_range); + + let mut sfnt = Vec::new(); + sfnt.extend_from_slice(&0x00010000u32.to_be_bytes()); + sfnt.extend_from_slice(&num_tables.to_be_bytes()); + sfnt.extend_from_slice(&search_range.to_be_bytes()); + sfnt.extend_from_slice(&entry_selector.to_be_bytes()); + sfnt.extend_from_slice(&range_shift.to_be_bytes()); + + let dir_size = 12usize.saturating_add(usize::from(num_tables).saturating_mul(16)); + let mut current_offset = dir_size; + + struct Rec { + tag: [u8; 4], + checksum: u32, + offset: u32, + length: u32, + } + let mut records = Vec::with_capacity(tables.len()); + for (tag, data) in &tables { + let checksum = calc_table_checksum(data); + let offset = u32::try_from(current_offset).map_err(|_| "font offset overflow")?; + let length = u32::try_from(data.len()).map_err(|_| "font table overflow")?; + records.push(Rec { tag: *tag, checksum, offset, length }); + let padded_len = (data.len().saturating_add(3)) & !3; + current_offset = current_offset.saturating_add(padded_len); + } + + for r in &records { + sfnt.extend_from_slice(&r.tag); + sfnt.extend_from_slice(&r.checksum.to_be_bytes()); + sfnt.extend_from_slice(&r.offset.to_be_bytes()); + sfnt.extend_from_slice(&r.length.to_be_bytes()); + } + + let mut head_offset = None; + for (r, (_, data)) in records.iter().zip(&tables) { + if &r.tag == b"head" { + head_offset = Some(sfnt.len()); + } + sfnt.extend_from_slice(data); + while sfnt.len() % 4 != 0 { + sfnt.push(0); + } + } + + if let Some(h_off) = head_offset { + let file_checksum = calc_table_checksum(&sfnt); + let adjustment = 0xB1B0AFBAu32.wrapping_sub(file_checksum); + if let Some(slot) = sfnt.get_mut(h_off.saturating_add(8)..h_off.saturating_add(12)) { + slot.copy_from_slice(&adjustment.to_be_bytes()); + } + } + + Ok((sfnt, old_to_new_gid)) +} + #[cfg(test)] mod tests { use super::*; @@ -488,4 +920,19 @@ mod tests { assert_eq!(programs, 1, "one program for both pages"); assert!(doc.scan_objects().count() > objects, "the second page gets its own resource"); } + + #[test] + fn subset_truetype_creates_valid_compact_font() { + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let face = FontRef::new(bytes).unwrap(); + let gid_t = face.charmap().map('T').unwrap().to_u32() as u16; + let gid_e = face.charmap().map('e').unwrap().to_u32() as u16; + let (subset, mapping) = subset_truetype(bytes, &[gid_t, gid_e], "ABCDEF+Inter-Regular").unwrap(); + assert!(subset.len() < 10_000, "subset should be tiny, was {}", subset.len()); + assert!(mapping.contains_key(&gid_t)); + assert!(mapping.contains_key(&gid_e)); + let sub_face = FontRef::new(&subset).unwrap(); + assert_eq!(sub_face.charmap().map('T').unwrap().to_u32(), u32::from(*mapping.get(&gid_t).unwrap())); + assert_eq!(sub_face.charmap().map('e').unwrap().to_u32(), u32::from(*mapping.get(&gid_e).unwrap())); + } }