mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
fix(fonts): subset added TrueType fonts to used glyphs on document save
This commit is contained in:
+266
-2
@@ -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<u8> {
|
||||
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<ObjRef> = 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<ObjRef, ObjRef> = HashMap::new();
|
||||
let mut cid_for_desc: HashMap<ObjRef, ObjRef> = HashMap::new();
|
||||
let mut type0_for_cid: HashMap<ObjRef, Vec<ObjRef>> = 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<ObjRef, ObjRef> = 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<ObjRef, BTreeSet<u16>> = 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<Vec<u8>, 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<Vec<u8>> = 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<u8>)> = 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<u16> = 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> <FFFF>\nendcodespacerange\n",
|
||||
);
|
||||
let used_cids_vec: Vec<u16> = 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<HashMap<[u8; 4], &[u8]>> {
|
||||
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<u16> {
|
||||
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<u16, u16>) -> Vec<u8> {
|
||||
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<u8> {
|
||||
let utf16_be: Vec<u8> = 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<u8> {
|
||||
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<u8>, HashMap<u16, u16>), 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<u16> = 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<u8>)> = 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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user