mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-11 16:30:16 -06:00
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
173 lines
7.2 KiB
Rust
173 lines
7.2 KiB
Rust
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
|
|
|
use hayro_syntax::content::UntypedIter;
|
|
use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier};
|
|
use hayro_syntax::page::Resources;
|
|
|
|
use super::MAX_DEPTH;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
const MAX_OPERATIONS: usize = 100_000;
|
|
const LIMIT_WARNING: &str = "font scan operation limit reached: PDF/X-4 font embedding checks are incomplete";
|
|
const DEPTH_WARNING: &str = "font scan nesting depth limit reached: PDF/X-4 font embedding checks are incomplete";
|
|
|
|
pub(super) fn unembedded_fonts(pdf: &[u8]) -> Vec<String> {
|
|
let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else { return Vec::new() };
|
|
let mut found: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
|
let mut scan = FontScan::default();
|
|
for (i, page) in file.pages().iter().enumerate() {
|
|
scan.font = None;
|
|
scan.mode = 0;
|
|
if let Some(content) = page.page_stream() {
|
|
scan.content(content, page.resources(), 0);
|
|
}
|
|
for name in std::mem::take(&mut scan.found) {
|
|
found.entry(name).or_default().push(i + 1);
|
|
}
|
|
if scan.limited {
|
|
break;
|
|
}
|
|
}
|
|
let mut warnings: Vec<String> = found
|
|
.into_iter()
|
|
.map(|(name, pages)| {
|
|
let list: Vec<String> = pages.iter().take(10).map(ToString::to_string).collect();
|
|
let more = if pages.len() > 10 { ", …" } else { "" };
|
|
let s = if pages.len() == 1 { "" } else { "s" };
|
|
format!("the font {name} isn't embedded (page{s} {}{more}): PDF/X-4 needs every font used for visible text embedded", list.join(", "))
|
|
})
|
|
.collect();
|
|
if scan.limited {
|
|
warnings.push(LIMIT_WARNING.to_string());
|
|
}
|
|
if scan.depth_limited {
|
|
warnings.push(DEPTH_WARNING.to_string());
|
|
}
|
|
warnings
|
|
}
|
|
|
|
fn font_embedded(font: &Dict<'_>) -> bool {
|
|
let descriptor = match font.get::<Name<'_>>(b"Subtype").as_deref() {
|
|
Some(b"Type3") => return true,
|
|
Some(b"Type0") => {
|
|
font.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next()).and_then(|d| d.get::<Dict<'_>>(b"FontDescriptor"))
|
|
}
|
|
_ => font.get::<Dict<'_>>(b"FontDescriptor"),
|
|
};
|
|
descriptor.is_some_and(|d| d.contains_key(b"FontFile") || d.contains_key(b"FontFile2") || d.contains_key(b"FontFile3"))
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FontScan<'a> {
|
|
found: BTreeSet<String>,
|
|
active: HashSet<ObjectIdentifier>,
|
|
path: Vec<ObjectIdentifier>,
|
|
completed: HashMap<FormContext<'a>, BTreeSet<String>>,
|
|
font: Option<Dict<'a>>,
|
|
mode: i32,
|
|
operations: usize,
|
|
limited: bool,
|
|
// A form nested too deeply to follow. The scan goes on (unlike `limited`), and the flag is
|
|
// never cleared, so a memoized subtree that was cut short can't hide it.
|
|
depth_limited: bool,
|
|
}
|
|
|
|
// Forms inherit text state and missing resource names. Depth and ancestors also affect
|
|
// which descendants the depth/cycle guards permit; only identical contexts may share results.
|
|
#[derive(Hash, PartialEq, Eq)]
|
|
struct FormContext<'a> {
|
|
form: ObjectIdentifier,
|
|
font: Option<&'a [u8]>,
|
|
mode: i32,
|
|
resources: Vec<(&'a [u8], &'a [u8])>,
|
|
ancestors: Vec<ObjectIdentifier>,
|
|
}
|
|
|
|
impl<'a> FontScan<'a> {
|
|
fn content(&mut self, data: &[u8], res: &Resources<'a>, depth: usize) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
let mut saved = Vec::new();
|
|
let mut ops = UntypedIter::new(data);
|
|
while let Some(op) = ops.next() {
|
|
if self.operations == MAX_OPERATIONS {
|
|
self.limited = true;
|
|
return;
|
|
}
|
|
self.operations += 1;
|
|
let operator: &[u8] = op.operator;
|
|
match operator {
|
|
b"q" => saved.push((self.font.clone(), self.mode)),
|
|
b"Q" => {
|
|
if let Some(state) = saved.pop() {
|
|
(self.font, self.mode) = state;
|
|
}
|
|
}
|
|
b"Tf" => self.font = op.operands().next().cloned().and_then(Object::into_name).and_then(|n| res.get_font(&n)),
|
|
b"Tr" => {
|
|
if let Some(mode) = op.operands().next().cloned().and_then(|n| i32::try_from(n).ok()) {
|
|
self.mode = mode;
|
|
}
|
|
}
|
|
b"Tj" | b"TJ" | b"'" | b"\"" if self.mode != 3 => {
|
|
if let Some(font) = self.font.as_ref().filter(|f| !font_embedded(f)) {
|
|
let name = font.get::<Name<'_>>(b"BaseFont").map_or_else(|| "(unnamed)".to_string(), |n| n.as_str().to_string());
|
|
self.found.insert(name);
|
|
}
|
|
}
|
|
b"Do" => {
|
|
let xo = op.operands().last().cloned().and_then(Object::into_name).and_then(|n| res.get_x_object(&n));
|
|
if let Some(form) = xo.filter(|x| x.dict().get::<Name<'_>>(b"Subtype").as_deref() == Some(b"Form"))
|
|
&& (depth < MAX_DEPTH || {
|
|
// A form already being scanned is a cycle: it can't hide new content.
|
|
self.depth_limited |= !self.active.contains(&form.obj_id());
|
|
false
|
|
})
|
|
&& self.active.insert(form.obj_id())
|
|
{
|
|
let mut resources = Vec::new();
|
|
let mut parent = Some(res);
|
|
while let Some(r) = parent {
|
|
resources.push((r.fonts.data(), r.x_objects.data()));
|
|
parent = r.parent();
|
|
}
|
|
let key = FormContext {
|
|
form: form.obj_id(),
|
|
font: self.font.as_ref().map(Dict::data),
|
|
mode: self.mode,
|
|
resources,
|
|
ancestors: self.path.clone(),
|
|
};
|
|
if let Some(found) = self.completed.get(&key) {
|
|
self.found.extend(found.iter().cloned());
|
|
self.active.remove(&form.obj_id());
|
|
continue;
|
|
}
|
|
let inner = Resources::from_parent(form.dict().get::<Dict<'_>>(b"Resources").unwrap_or_default(), res.clone());
|
|
let state = (self.font.clone(), self.mode);
|
|
let outer_found = std::mem::take(&mut self.found);
|
|
self.path.push(form.obj_id());
|
|
if let Ok(data) = form.decoded() {
|
|
self.content(&data, &inner, depth + 1);
|
|
}
|
|
self.path.pop();
|
|
if !self.limited {
|
|
self.completed.insert(key, self.found.clone());
|
|
}
|
|
self.found.extend(outer_found);
|
|
(self.font, self.mode) = state;
|
|
self.active.remove(&form.obj_id());
|
|
if self.limited {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|