From 0988209798f0ccc9826aab3894865ae6b1dedbed Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:11:40 +0000 Subject: [PATCH] refactor(xfa): split pdf.rs into pdf/ New modules: helpers, strip. --- crates/xfa/src/pdf.rs | 490 +---------------------------------- crates/xfa/src/pdf/common.rs | 200 ++++++++++++++ crates/xfa/src/pdf/strip.rs | 282 ++++++++++++++++++++ 3 files changed, 492 insertions(+), 480 deletions(-) create mode 100644 crates/xfa/src/pdf/common.rs create mode 100644 crates/xfa/src/pdf/strip.rs diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 23e21fc331..ab5d94bb45 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -12,6 +12,16 @@ use crate::layout::{Action, BorderShape, Form, Item, Page, Rounded, Widget, Widg use crate::model::Rect; use crate::text::{Face, Family, Span}; +mod common; +mod strip; + +pub use common::jpeg_size; +pub(crate) use common::rounded; +use common::{all_faces, content_hash, empty_form, hex32, jpeg_info, js_action, n, parse_hex32, rgb, rgb_array, standard_font, state_name}; +pub(crate) use strip::USAGE_RIGHTS_REMOVED; +use strip::drop_usage_rights; +pub use strip::{Stripped, XfaShape, existing_layout, strip, xfa_shape}; + /// Private key on generated fields: the template's SOM path, so data can be put back. pub const SOM_KEY: &[u8] = b"PCSom"; /// Private key on the AcroForm: what laying the form out produced, so a reopened file is not @@ -26,209 +36,6 @@ pub const CLICK_KEY: &[u8] = b"PCXfaClick"; /// fields they hold. pub const NODE_KEY: &[u8] = b"PCXfaNode"; -/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT -/// markers). `None` for anything else. -pub fn jpeg_size(data: &[u8]) -> Option<(u32, u32)> { - jpeg_frame(data).map(|(w, h, _)| (w, h)) -} - -fn jpeg_frame(data: &[u8]) -> Option<(u32, u32, u8)> { - jpeg_info(data).map(|(w, h, c, _)| (w, h, c)) -} - -/// The SHA-256 of a picture's bytes, to find its XObject again. -fn content_hash(data: &[u8]) -> [u8; 32] { - use sha2::Digest; - sha2::Sha256::digest(data).into() -} - -fn hex32(h: &[u8; 32]) -> Vec { - h.iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect() -} - -fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> { - let mut out = [0u8; 32]; - if s.len() != 64 || !s.iter().all(u8::is_ascii_hexdigit) { - return None; - } - for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) { - *o = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?; - } - Some(out) -} - -/// Width, height, components and whether an Adobe APP14 segment is present (such CMYK JPEGs -/// store inverted values). -fn jpeg_info(data: &[u8]) -> Option<(u32, u32, u8, bool)> { - if data.get(0..2)? != [0xFF, 0xD8] { - return None; - } - let mut i = 2usize; - let mut guard = 0; - let mut adobe = false; - while i + 4 <= data.len() && guard < 10_000 { - guard += 1; - if *data.get(i)? != 0xFF { - i += 1; - continue; - } - let marker = *data.get(i + 1)?; - if marker == 0xFF { - i += 1; - continue; - } - if (0xD0..=0xD9).contains(&marker) || marker == 0x01 { - i += 2; - continue; - } - let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize; - if marker == 0xEE && data.get(i + 4..i + 9) == Some(b"Adobe") { - adobe = true; - } - let sof = matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC); - if sof { - let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32; - let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32; - let comps = *data.get(i + 9)?; - return (w > 0 && h > 0).then_some((w, h, comps, adobe)); - } - i = i.checked_add(2)?.checked_add(len.max(2))?; - } - None -} - -/// Format a number for content streams. -fn n(v: f64) -> String { - let v = if v.is_finite() { v } else { 0.0 }; - let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -fn rgb(c: [f64; 3]) -> String { - format!("{} {} {}", n(c[0]), n(c[1]), n(c[2])) -} - -fn rgb_array(c: [f64; 3]) -> Object { - Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()) -} - -fn standard_font(base: &str) -> Object { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - Object::Dict(f) -} - -/// Every face the content streams or `/DA` strings may name. -fn all_faces() -> Vec<(&'static str, &'static str)> { - let mut out = Vec::new(); - for fam in [Family::Helvetica, Family::Courier, Family::Times] { - for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { - out.push((fam.resource_name(b, i), fam.base_font(b, i))); - } - } - out -} - -/// The content that draws rounded box `r` on a page `page_h` tall: its fill along its curves, -/// then its edge inside it. A side a page break cut has no edge. -pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String { - let mut c = String::new(); - if let Some(color) = r.fill { - c.push_str(&format!("{} rg {}f\n", rgb(color), outline(r.rect, r.radii, r.inverted, (false, false), page_h))); - } - if let Some((w, color, dashed)) = r.edge { - // The edge's centre line, half its width inside the box, so the edge stays in: a corner - // curving around the box tightens by as much, and one curving into it widens. - let inset = Rect::new(r.rect.x + w / 2.0, r.rect.y + w / 2.0, (r.rect.w - w).max(0.0), (r.rect.h - w).max(0.0)); - let most = inset.w.min(inset.h) / 2.0; - let radii: [f64; 4] = std::array::from_fn(|i| match (r.radii[i], r.inverted[i]) { - (v, _) if v <= 0.0 => 0.0, - (v, true) => (v + w / 2.0).min(most), - (v, false) => (v - w / 2.0).clamp(0.0, most), - }); - let path = outline(inset, radii, r.inverted, (r.open_top, r.open_bottom), page_h); - c.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), if dashed { "[2 2] 0 d " } else { "" })); - } - c -} - -/// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii` -/// (top-left, top-right, bottom-right, bottom-left), each curving around the box or, when -/// `inverted`, into it. `open` leaves out the top or bottom side (and doesn't close the path): -/// those are where a page break cut the box. Ends with a space, for the paint operator. -fn outline(rect: Rect, radii: [f64; 4], inverted: [bool; 4], open: (bool, bool), page_h: f64) -> String { - let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); - let pt = |q: (f64, f64)| format!("{} {}", n(q.0), n(page_h - q.1)); - // Corner `i`: its point, then where its curve starts and ends, going clockwise. - let corner = |i: usize| { - let r = radii.get(i).copied().unwrap_or(0.0); - match i { - 0 => ((x0, y0), (x0, y0 + r), (x0 + r, y0)), - 1 => ((x1, y0), (x1 - r, y0), (x1, y0 + r)), - 2 => ((x1, y1), (x1, y1 - r), (x1 - r, y1)), - _ => ((x0, y1), (x0 + r, y1), (x0, y1 - r)), - } - }; - // Corner `i`'s curve, from its start: a quarter circle as one Bézier curve. - let curve = |i: usize| { - if radii.get(i).copied().unwrap_or(0.0) <= 0.0 { - return String::new(); - } - let (p, a, b) = corner(i); - const K: f64 = 0.5523; - let (c1, c2) = if inverted.get(i).copied().unwrap_or(false) { - // Centred on the corner itself. - ((a.0 + (b.0 - p.0) * K, a.1 + (b.1 - p.1) * K), (b.0 + (a.0 - p.0) * K, b.1 + (a.1 - p.1) * K)) - } else { - ((a.0 + (p.0 - a.0) * K, a.1 + (p.1 - a.1) * K), (b.0 + (p.0 - b.0) * K, b.1 + (p.1 - b.1) * K)) - }; - format!("{} {} {} c ", pt(c1), pt(c2), pt(b)) - }; - // Along corners `from`, joining them with straight sides. - let along = |from: &[usize]| { - let mut s = String::new(); - for &i in from { - s.push_str(&format!("{} l {}", pt(corner(i).1), curve(i))); - } - s - }; - match open { - (false, false) => format!("{} m {}{}{}{}h ", pt(corner(0).1), curve(0), along(&[1]), along(&[2]), along(&[3])), - (true, false) => format!("{} m {}{} l ", pt((x1, y0)), along(&[2, 3]), pt((x0, y0))), - (false, true) => format!("{} m {}{} l ", pt((x0, y1)), along(&[0, 1]), pt((x1, y1))), - (true, true) => format!("{} m {} l {} m {} l ", pt((x0, y0)), pt((x0, y1)), pt((x1, y0)), pt((x1, y1))), - } -} - -/// A PDF appearance-state name for a check button's on value. -fn state_name(on: &str) -> String { - let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect(); - if s.is_empty() || s == "Off" { "Yes".into() } else { s } -} - -fn empty_form(doc: &mut Document, w: f64, h: f64) -> Object { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|v| Object::Real(*v)).collect())); - Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new())))) -} - -fn js_action(js: &str) -> Object { - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), Object::String(PdfString::literal(js.as_bytes().to_vec()))); - Object::Dict(a) -} - -/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). -pub(crate) const USAGE_RIGHTS_REMOVED: &str = - "the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked"; - pub struct Written { pub pages: usize, pub fields: usize, @@ -1133,283 +940,6 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result Ok(Written { pages: count, fields: field_count, warnings, usage_rights }) } -/// What [`strip`] took out, for the user to be told (the packets held more than the fields). -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Stripped { - /// The packets removed, by name (`template`, `datasets`, `config`, `signature`, …). - pub packets: Vec, - /// Data values in the datasets no field held (what the form's data had that its fields - /// didn't show); `None` when the template could not be read to tell. - pub unbound_data: Option, - /// Buttons whose click ran an XFA script, and image fields: they do nothing from now on. - pub script_buttons: usize, - pub image_fields: usize, - /// What the layout left out (pictures, rows past the cap), now for good. - pub layout_warnings: Vec, - /// Reader usage rights (`/Perms /UR3`) were removed: changing the form voids them anyway. - pub usage_rights: bool, - /// Private keys removed from field dictionaries (`/PCSom`, `/PCXfaClick`, …). - pub field_keys: usize, -} - -impl Stripped { - /// What was dropped, as lines for the user (empty when nothing worth saying). - pub fn notes(&self) -> Vec { - let mut out = Vec::new(); - if !self.packets.is_empty() { - out.push(format!("The XFA was taken out of the form: its {} packet(s) are gone from the saved file", self.packets.join(", "))); - } - match self.unbound_data { - Some(n) if n > 0 => out.push(format!("{n} value(s) the form's XFA data held that no field showed are gone with it")), - None => out.push("The template could not be read, so data the fields didn't show may be gone with the XFA".into()), - _ => {} - } - if self.script_buttons + self.image_fields > 0 { - out.push(format!("{} button(s) that ran XFA scripts and {} image field(s) do nothing now", self.script_buttons, self.image_fields)); - } - for w in &self.layout_warnings { - out.push(format!("Kept as laid out: {w}")); - } - if self.usage_rights { - out.push("Adobe Reader extended features were removed (changing the form voids them)".into()); - } - out - } -} - -/// What a document's XFA entry says, from its dictionaries alone (no packet decoded). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct XfaShape { - /// `/NeedsRendering true`: the form expects to be laid out from the template. - pub needs_rendering: bool, - /// The AcroForm has fields of its own. - pub has_fields: bool, - /// PdfCraft laid the form out (the layout mark is there). - pub laid_out: bool, -} - -impl XfaShape { - /// Whether taking the XFA out leaves a form every viewer shows: the pages PdfCraft laid out - /// (with their fields), or a static form's own pages and fields. A dynamic form that was - /// never laid out has only placeholder pages: stripping it would leave nothing. - pub fn strippable(self) -> bool { - self.laid_out || (!self.needs_rendering && self.has_fields) - } -} - -/// The document's XFA entry, if any, without decoding a packet. -pub fn xfa_shape(doc: &Document) -> Option { - let root = doc.root()?; - let catalog = doc.get(root); - let catalog = catalog.as_dict()?; - let acro = doc.resolve(catalog.get(b"AcroForm")?); - let acro = acro.as_dict()?; - acro.get(b"XFA")?; - let bool_of = |v: Option<&Object>| matches!(v.map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))); - let needs_rendering = bool_of(catalog.get(b"NeedsRendering")) || bool_of(acro.get(b"NeedsRendering")); - let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false); - Some(XfaShape { needs_rendering, has_fields, laid_out: acro.contains(LAYOUT_KEY) }) -} - -/// Most field dictionaries walked for their private keys. -const MAX_STRIP_FIELDS: usize = 1_000_000; - -/// Take the XFA out of a document, leaving what every viewer shows: the pages as laid out -/// and, when the fields were kept, an ordinary AcroForm. The packets (template, data and -/// all), `/NeedsRendering`, the layout mark, the overrides and Reader usage rights go, and -/// the fields lose the private keys PdfCraft gave them. A flattened form (no fields left) -/// becomes a plain PDF; one with its fields becomes a static form that Adobe's viewers no -/// longer lay out again. What the packets held beyond the fields is counted in the report, -/// so the user can be told; the caller decides whether the form is one to strip -/// ([`XfaShape::strippable`]). A document without XFA is left as it is. -pub fn strip(doc: &mut Document) -> Result { - let mut out = Stripped::default(); - let Some(root) = doc.root() else { return Ok(out) }; - let Some(mut catalog) = doc.get(root).as_dict().cloned() else { return Ok(out) }; - let acro_obj = catalog.get(b"AcroForm").cloned(); - let Some(acro_obj) = acro_obj else { return Ok(out) }; - let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(out) }; - let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(out) }; - // What is about to go, while it can still be read. - out.unbound_data = unbound_data(doc); - out.layout_warnings = existing_layout(doc).map(|r| r.warnings).unwrap_or_default(); - let mut streams: Vec = Vec::new(); - match &*doc.resolve(&xfa) { - Object::Array(items) => { - let mut i = 0; - while i < items.len() { - let name = items.get(i).and_then(|n| n.as_string()).map(|s| s.to_text()); - let stream = if name.is_some() { items.get(i + 1) } else { items.get(i) }; - if let Some(n) = &name { - out.packets.push(n.clone()); - } - if let Some(r) = stream.and_then(Object::as_ref) { - streams.push(r); - } - i += if name.is_some() { 2 } else { 1 }; - } - } - Object::Stream(s) => { - if let Some(r) = xfa.as_ref() { - streams.push(r); - } - // One XDP: the packets it holds are the envelope's children. - let text = s.decoded_within(64 << 20).ok().and_then(|b| crate::packets::decode(&b)).map(|(t, _)| t).unwrap_or_default(); - let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 1_000_000 }; - if crate::xml_nesting_depth(&text) <= crate::MAX_XML_DEPTH - && let Ok(parsed) = roxmltree::Document::parse_with_options(&text, opts) - { - let root = parsed.root_element(); - if root.tag_name().name() == "xdp" { - out.packets.extend(root.children().filter(|c| c.is_element()).map(|c| c.tag_name().name().to_string()).take(32)); - } else { - out.packets.push(root.tag_name().name().to_string()); - } - } - if out.packets.is_empty() { - out.packets.push("xdp".into()); - } - } - _ => {} - } - acro.remove(b"XFA"); - for key in [b"NeedsRendering".as_slice(), LAYOUT_KEY, crate::script::OVERRIDES_KEY] { - acro.remove(key); - } - // The private keys on every field and widget, down the tree (each dictionary once). - let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - let mut seen = std::collections::HashSet::new(); - let mut todo: Vec = Vec::new(); - for r in fields.iter().filter_map(Object::as_ref) { - if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS { - todo.push(r); - } - } - while let Some(r) = todo.pop() { - let Some(d) = doc.get(r).as_dict().cloned() else { continue }; - if d.contains(CLICK_KEY) { - out.script_buttons += 1; - } - if d.contains(IMAGE_FIELD_KEY) { - out.image_fields += 1; - } - let private: Vec> = - [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, NODE_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect(); - if !private.is_empty() { - out.field_keys += private.len(); - doc.update_dict(r, |d| { - for k in &private { - d.remove(k); - } - })?; - } - if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { - for k in kids.iter().filter_map(Object::as_ref) { - if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS { - todo.push(k); - } - } - } - } - match acro_obj.as_ref() { - Some(r) => doc.set(r, Object::Dict(acro)), - None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)), - } - catalog.remove(b"NeedsRendering"); - out.usage_rights = drop_usage_rights(doc, &mut catalog); - doc.set(root, Object::Dict(catalog)); - // The packet streams are referenced from nowhere else: freed, so an incremental save - // doesn't carry an unreachable copy of the data. - for r in streams { - doc.free(r); - } - doc.require_full_save(); - Ok(out) -} - -/// Values in the datasets that no field of the laid-out form holds (leaf text nodes whose -/// data path no field binds to). `None` when the template can't be read. -fn unbound_data(doc: &Document) -> Option { - let data = crate::script::data_of(doc)?; - let tpl = crate::script::template_of(doc).ok()?; - let bound: std::collections::HashSet = - crate::script::fields_by_som(doc).keys().map(|som| crate::data::data_path(&tpl, som)).collect(); - fn walk( - n: &crate::data::DataNode, - path: &mut crate::data::DataPath, - bound: &std::collections::HashSet, - count: &mut usize, - depth: usize, - ) { - if depth > 64 { - return; - } - let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); - for c in &n.children { - let idx = seen.entry(c.name.as_str()).or_insert(0); - path.push((c.name.clone(), *idx)); - *idx += 1; - if c.children.is_empty() { - if !c.text.trim().is_empty() && !bound.contains(path) { - *count += 1; - } - } else { - walk(c, path, bound, count, depth + 1); - } - path.pop(); - } - } - let mut count = 0; - walk(&data, &mut Vec::new(), &bound, &mut count, 0); - Some(count) -} - -/// Take the usage rights (`/Perms /UR3`, or the older `/UR`) off a catalog, keeping the rest of -/// `/Perms` (a certification's `/DocMDP`). They are signed over the form as it was: once its -/// pages or fields change they are void, and a viewer may lock every field of a form whose -/// usage rights are void, so they go, and the caller says so. Whether there were any. -fn drop_usage_rights(doc: &mut Document, catalog: &mut Dict) -> bool { - let Some(entry) = catalog.get(b"Perms").cloned() else { return false }; - let Some(mut perms) = doc.resolve(&entry).as_dict().cloned() else { return false }; - if !perms.contains(b"UR3") && !perms.contains(b"UR") { - return false; - } - perms.remove(b"UR3"); - perms.remove(b"UR"); - match entry.as_ref() { - _ if perms.is_empty() => { - catalog.remove(b"Perms"); - } - Some(r) => doc.set(r, Object::Dict(perms)), - None => catalog.set(b"Perms".to_vec(), Object::Dict(perms)), - } - true -} - -/// The layout a document already carries from an earlier pass, if any. -pub fn existing_layout(doc: &Document) -> Option { - let root = doc.root()?; - let catalog = doc.get(root); - let acro = doc.resolve(catalog.as_dict()?.get(b"AcroForm")?); - let mark = doc.resolve(acro.as_dict()?.get(LAYOUT_KEY)?); - let mark = mark.as_dict()?; - let warnings = mark - .get(b"Warnings") - .map(|w| doc.resolve(w)) - .and_then(|w| w.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|w| w.as_string().map(|s| s.to_text())) - .take(100) - .collect(); - Some(crate::Report { - pages: mark.int(b"Pages").unwrap_or(0).clamp(0, 100_000) as usize, - fields: mark.int(b"Fields").unwrap_or(0).clamp(0, 1_000_000) as usize, - template_bytes: 0, - warnings, - }) -} - /// The rectangle of a widget in PDF user space (for tests). pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] { [r.x, page_height - r.bottom(), r.right(), page_height - r.y] diff --git a/crates/xfa/src/pdf/common.rs b/crates/xfa/src/pdf/common.rs new file mode 100644 index 0000000000..e1325b19ae --- /dev/null +++ b/crates/xfa/src/pdf/common.rs @@ -0,0 +1,200 @@ +use super::*; + +/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT +/// markers). `None` for anything else. +pub fn jpeg_size(data: &[u8]) -> Option<(u32, u32)> { + jpeg_frame(data).map(|(w, h, _)| (w, h)) +} + +fn jpeg_frame(data: &[u8]) -> Option<(u32, u32, u8)> { + jpeg_info(data).map(|(w, h, c, _)| (w, h, c)) +} + +/// The SHA-256 of a picture's bytes, to find its XObject again. +pub(super) fn content_hash(data: &[u8]) -> [u8; 32] { + use sha2::Digest; + sha2::Sha256::digest(data).into() +} + +pub(super) fn hex32(h: &[u8; 32]) -> Vec { + h.iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect() +} + +pub(super) fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> { + let mut out = [0u8; 32]; + if s.len() != 64 || !s.iter().all(u8::is_ascii_hexdigit) { + return None; + } + for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) { + *o = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?; + } + Some(out) +} + +/// Width, height, components and whether an Adobe APP14 segment is present (such CMYK JPEGs +/// store inverted values). +pub(super) fn jpeg_info(data: &[u8]) -> Option<(u32, u32, u8, bool)> { + if data.get(0..2)? != [0xFF, 0xD8] { + return None; + } + let mut i = 2usize; + let mut guard = 0; + let mut adobe = false; + while i + 4 <= data.len() && guard < 10_000 { + guard += 1; + if *data.get(i)? != 0xFF { + i += 1; + continue; + } + let marker = *data.get(i + 1)?; + if marker == 0xFF { + i += 1; + continue; + } + if (0xD0..=0xD9).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize; + if marker == 0xEE && data.get(i + 4..i + 9) == Some(b"Adobe") { + adobe = true; + } + let sof = matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC); + if sof { + let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32; + let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32; + let comps = *data.get(i + 9)?; + return (w > 0 && h > 0).then_some((w, h, comps, adobe)); + } + i = i.checked_add(2)?.checked_add(len.max(2))?; + } + None +} + +/// Format a number for content streams. +pub(super) fn n(v: f64) -> String { + let v = if v.is_finite() { v } else { 0.0 }; + let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +pub(super) fn rgb(c: [f64; 3]) -> String { + format!("{} {} {}", n(c[0]), n(c[1]), n(c[2])) +} + +pub(super) fn rgb_array(c: [f64; 3]) -> Object { + Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()) +} + +pub(super) fn standard_font(base: &str) -> Object { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + Object::Dict(f) +} + +/// Every face the content streams or `/DA` strings may name. +pub(super) fn all_faces() -> Vec<(&'static str, &'static str)> { + let mut out = Vec::new(); + for fam in [Family::Helvetica, Family::Courier, Family::Times] { + for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { + out.push((fam.resource_name(b, i), fam.base_font(b, i))); + } + } + out +} + +/// The content that draws rounded box `r` on a page `page_h` tall: its fill along its curves, +/// then its edge inside it. A side a page break cut has no edge. +pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String { + let mut c = String::new(); + if let Some(color) = r.fill { + c.push_str(&format!("{} rg {}f\n", rgb(color), outline(r.rect, r.radii, r.inverted, (false, false), page_h))); + } + if let Some((w, color, dashed)) = r.edge { + // The edge's centre line, half its width inside the box, so the edge stays in: a corner + // curving around the box tightens by as much, and one curving into it widens. + let inset = Rect::new(r.rect.x + w / 2.0, r.rect.y + w / 2.0, (r.rect.w - w).max(0.0), (r.rect.h - w).max(0.0)); + let most = inset.w.min(inset.h) / 2.0; + let radii: [f64; 4] = std::array::from_fn(|i| match (r.radii[i], r.inverted[i]) { + (v, _) if v <= 0.0 => 0.0, + (v, true) => (v + w / 2.0).min(most), + (v, false) => (v - w / 2.0).clamp(0.0, most), + }); + let path = outline(inset, radii, r.inverted, (r.open_top, r.open_bottom), page_h); + c.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), if dashed { "[2 2] 0 d " } else { "" })); + } + c +} + +/// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii` +/// (top-left, top-right, bottom-right, bottom-left), each curving around the box or, when +/// `inverted`, into it. `open` leaves out the top or bottom side (and doesn't close the path): +/// those are where a page break cut the box. Ends with a space, for the paint operator. +fn outline(rect: Rect, radii: [f64; 4], inverted: [bool; 4], open: (bool, bool), page_h: f64) -> String { + let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); + let pt = |q: (f64, f64)| format!("{} {}", n(q.0), n(page_h - q.1)); + // Corner `i`: its point, then where its curve starts and ends, going clockwise. + let corner = |i: usize| { + let r = radii.get(i).copied().unwrap_or(0.0); + match i { + 0 => ((x0, y0), (x0, y0 + r), (x0 + r, y0)), + 1 => ((x1, y0), (x1 - r, y0), (x1, y0 + r)), + 2 => ((x1, y1), (x1, y1 - r), (x1 - r, y1)), + _ => ((x0, y1), (x0 + r, y1), (x0, y1 - r)), + } + }; + // Corner `i`'s curve, from its start: a quarter circle as one Bézier curve. + let curve = |i: usize| { + if radii.get(i).copied().unwrap_or(0.0) <= 0.0 { + return String::new(); + } + let (p, a, b) = corner(i); + const K: f64 = 0.5523; + let (c1, c2) = if inverted.get(i).copied().unwrap_or(false) { + // Centred on the corner itself. + ((a.0 + (b.0 - p.0) * K, a.1 + (b.1 - p.1) * K), (b.0 + (a.0 - p.0) * K, b.1 + (a.1 - p.1) * K)) + } else { + ((a.0 + (p.0 - a.0) * K, a.1 + (p.1 - a.1) * K), (b.0 + (p.0 - b.0) * K, b.1 + (p.1 - b.1) * K)) + }; + format!("{} {} {} c ", pt(c1), pt(c2), pt(b)) + }; + // Along corners `from`, joining them with straight sides. + let along = |from: &[usize]| { + let mut s = String::new(); + for &i in from { + s.push_str(&format!("{} l {}", pt(corner(i).1), curve(i))); + } + s + }; + match open { + (false, false) => format!("{} m {}{}{}{}h ", pt(corner(0).1), curve(0), along(&[1]), along(&[2]), along(&[3])), + (true, false) => format!("{} m {}{} l ", pt((x1, y0)), along(&[2, 3]), pt((x0, y0))), + (false, true) => format!("{} m {}{} l ", pt((x0, y1)), along(&[0, 1]), pt((x1, y1))), + (true, true) => format!("{} m {} l {} m {} l ", pt((x0, y0)), pt((x0, y1)), pt((x1, y0)), pt((x1, y1))), + } +} + +/// A PDF appearance-state name for a check button's on value. +pub(super) fn state_name(on: &str) -> String { + let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect(); + if s.is_empty() || s == "Off" { "Yes".into() } else { s } +} + +pub(super) fn empty_form(doc: &mut Document, w: f64, h: f64) -> Object { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|v| Object::Real(*v)).collect())); + Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new())))) +} + +pub(super) fn js_action(js: &str) -> Object { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), Object::String(PdfString::literal(js.as_bytes().to_vec()))); + Object::Dict(a) +} diff --git a/crates/xfa/src/pdf/strip.rs b/crates/xfa/src/pdf/strip.rs new file mode 100644 index 0000000000..de395e37e9 --- /dev/null +++ b/crates/xfa/src/pdf/strip.rs @@ -0,0 +1,282 @@ +use super::*; + +/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). +pub(crate) const USAGE_RIGHTS_REMOVED: &str = + "the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked"; + +/// What [`strip`] took out, for the user to be told (the packets held more than the fields). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Stripped { + /// The packets removed, by name (`template`, `datasets`, `config`, `signature`, …). + pub packets: Vec, + /// Data values in the datasets no field held (what the form's data had that its fields + /// didn't show); `None` when the template could not be read to tell. + pub unbound_data: Option, + /// Buttons whose click ran an XFA script, and image fields: they do nothing from now on. + pub script_buttons: usize, + pub image_fields: usize, + /// What the layout left out (pictures, rows past the cap), now for good. + pub layout_warnings: Vec, + /// Reader usage rights (`/Perms /UR3`) were removed: changing the form voids them anyway. + pub usage_rights: bool, + /// Private keys removed from field dictionaries (`/PCSom`, `/PCXfaClick`, …). + pub field_keys: usize, +} + +impl Stripped { + /// What was dropped, as lines for the user (empty when nothing worth saying). + pub fn notes(&self) -> Vec { + let mut out = Vec::new(); + if !self.packets.is_empty() { + out.push(format!("The XFA was taken out of the form: its {} packet(s) are gone from the saved file", self.packets.join(", "))); + } + match self.unbound_data { + Some(n) if n > 0 => out.push(format!("{n} value(s) the form's XFA data held that no field showed are gone with it")), + None => out.push("The template could not be read, so data the fields didn't show may be gone with the XFA".into()), + _ => {} + } + if self.script_buttons + self.image_fields > 0 { + out.push(format!("{} button(s) that ran XFA scripts and {} image field(s) do nothing now", self.script_buttons, self.image_fields)); + } + for w in &self.layout_warnings { + out.push(format!("Kept as laid out: {w}")); + } + if self.usage_rights { + out.push("Adobe Reader extended features were removed (changing the form voids them)".into()); + } + out + } +} + +/// What a document's XFA entry says, from its dictionaries alone (no packet decoded). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct XfaShape { + /// `/NeedsRendering true`: the form expects to be laid out from the template. + pub needs_rendering: bool, + /// The AcroForm has fields of its own. + pub has_fields: bool, + /// PdfCraft laid the form out (the layout mark is there). + pub laid_out: bool, +} + +impl XfaShape { + /// Whether taking the XFA out leaves a form every viewer shows: the pages PdfCraft laid out + /// (with their fields), or a static form's own pages and fields. A dynamic form that was + /// never laid out has only placeholder pages: stripping it would leave nothing. + pub fn strippable(self) -> bool { + self.laid_out || (!self.needs_rendering && self.has_fields) + } +} + +/// The document's XFA entry, if any, without decoding a packet. +pub fn xfa_shape(doc: &Document) -> Option { + let root = doc.root()?; + let catalog = doc.get(root); + let catalog = catalog.as_dict()?; + let acro = doc.resolve(catalog.get(b"AcroForm")?); + let acro = acro.as_dict()?; + acro.get(b"XFA")?; + let bool_of = |v: Option<&Object>| matches!(v.map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))); + let needs_rendering = bool_of(catalog.get(b"NeedsRendering")) || bool_of(acro.get(b"NeedsRendering")); + let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false); + Some(XfaShape { needs_rendering, has_fields, laid_out: acro.contains(LAYOUT_KEY) }) +} + +/// Most field dictionaries walked for their private keys. +const MAX_STRIP_FIELDS: usize = 1_000_000; + +/// Take the XFA out of a document, leaving what every viewer shows: the pages as laid out +/// and, when the fields were kept, an ordinary AcroForm. The packets (template, data and +/// all), `/NeedsRendering`, the layout mark, the overrides and Reader usage rights go, and +/// the fields lose the private keys PdfCraft gave them. A flattened form (no fields left) +/// becomes a plain PDF; one with its fields becomes a static form that Adobe's viewers no +/// longer lay out again. What the packets held beyond the fields is counted in the report, +/// so the user can be told; the caller decides whether the form is one to strip +/// ([`XfaShape::strippable`]). A document without XFA is left as it is. +pub fn strip(doc: &mut Document) -> Result { + let mut out = Stripped::default(); + let Some(root) = doc.root() else { return Ok(out) }; + let Some(mut catalog) = doc.get(root).as_dict().cloned() else { return Ok(out) }; + let acro_obj = catalog.get(b"AcroForm").cloned(); + let Some(acro_obj) = acro_obj else { return Ok(out) }; + let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(out) }; + let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(out) }; + // What is about to go, while it can still be read. + out.unbound_data = unbound_data(doc); + out.layout_warnings = existing_layout(doc).map(|r| r.warnings).unwrap_or_default(); + let mut streams: Vec = Vec::new(); + match &*doc.resolve(&xfa) { + Object::Array(items) => { + let mut i = 0; + while i < items.len() { + let name = items.get(i).and_then(|n| n.as_string()).map(|s| s.to_text()); + let stream = if name.is_some() { items.get(i + 1) } else { items.get(i) }; + if let Some(n) = &name { + out.packets.push(n.clone()); + } + if let Some(r) = stream.and_then(Object::as_ref) { + streams.push(r); + } + i += if name.is_some() { 2 } else { 1 }; + } + } + Object::Stream(s) => { + if let Some(r) = xfa.as_ref() { + streams.push(r); + } + // One XDP: the packets it holds are the envelope's children. + let text = s.decoded_within(64 << 20).ok().and_then(|b| crate::packets::decode(&b)).map(|(t, _)| t).unwrap_or_default(); + let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 1_000_000 }; + if crate::xml_nesting_depth(&text) <= crate::MAX_XML_DEPTH + && let Ok(parsed) = roxmltree::Document::parse_with_options(&text, opts) + { + let root = parsed.root_element(); + if root.tag_name().name() == "xdp" { + out.packets.extend(root.children().filter(|c| c.is_element()).map(|c| c.tag_name().name().to_string()).take(32)); + } else { + out.packets.push(root.tag_name().name().to_string()); + } + } + if out.packets.is_empty() { + out.packets.push("xdp".into()); + } + } + _ => {} + } + acro.remove(b"XFA"); + for key in [b"NeedsRendering".as_slice(), LAYOUT_KEY, crate::script::OVERRIDES_KEY] { + acro.remove(key); + } + // The private keys on every field and widget, down the tree (each dictionary once). + let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + let mut seen = std::collections::HashSet::new(); + let mut todo: Vec = Vec::new(); + for r in fields.iter().filter_map(Object::as_ref) { + if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS { + todo.push(r); + } + } + while let Some(r) = todo.pop() { + let Some(d) = doc.get(r).as_dict().cloned() else { continue }; + if d.contains(CLICK_KEY) { + out.script_buttons += 1; + } + if d.contains(IMAGE_FIELD_KEY) { + out.image_fields += 1; + } + let private: Vec> = + [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, NODE_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect(); + if !private.is_empty() { + out.field_keys += private.len(); + doc.update_dict(r, |d| { + for k in &private { + d.remove(k); + } + })?; + } + if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + for k in kids.iter().filter_map(Object::as_ref) { + if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS { + todo.push(k); + } + } + } + } + match acro_obj.as_ref() { + Some(r) => doc.set(r, Object::Dict(acro)), + None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)), + } + catalog.remove(b"NeedsRendering"); + out.usage_rights = drop_usage_rights(doc, &mut catalog); + doc.set(root, Object::Dict(catalog)); + // The packet streams are referenced from nowhere else: freed, so an incremental save + // doesn't carry an unreachable copy of the data. + for r in streams { + doc.free(r); + } + doc.require_full_save(); + Ok(out) +} + +/// Values in the datasets that no field of the laid-out form holds (leaf text nodes whose +/// data path no field binds to). `None` when the template can't be read. +fn unbound_data(doc: &Document) -> Option { + let data = crate::script::data_of(doc)?; + let tpl = crate::script::template_of(doc).ok()?; + let bound: std::collections::HashSet = + crate::script::fields_by_som(doc).keys().map(|som| crate::data::data_path(&tpl, som)).collect(); + fn walk( + n: &crate::data::DataNode, + path: &mut crate::data::DataPath, + bound: &std::collections::HashSet, + count: &mut usize, + depth: usize, + ) { + if depth > 64 { + return; + } + let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for c in &n.children { + let idx = seen.entry(c.name.as_str()).or_insert(0); + path.push((c.name.clone(), *idx)); + *idx += 1; + if c.children.is_empty() { + if !c.text.trim().is_empty() && !bound.contains(path) { + *count += 1; + } + } else { + walk(c, path, bound, count, depth + 1); + } + path.pop(); + } + } + let mut count = 0; + walk(&data, &mut Vec::new(), &bound, &mut count, 0); + Some(count) +} + +/// Take the usage rights (`/Perms /UR3`, or the older `/UR`) off a catalog, keeping the rest of +/// `/Perms` (a certification's `/DocMDP`). They are signed over the form as it was: once its +/// pages or fields change they are void, and a viewer may lock every field of a form whose +/// usage rights are void, so they go, and the caller says so. Whether there were any. +pub(super) fn drop_usage_rights(doc: &mut Document, catalog: &mut Dict) -> bool { + let Some(entry) = catalog.get(b"Perms").cloned() else { return false }; + let Some(mut perms) = doc.resolve(&entry).as_dict().cloned() else { return false }; + if !perms.contains(b"UR3") && !perms.contains(b"UR") { + return false; + } + perms.remove(b"UR3"); + perms.remove(b"UR"); + match entry.as_ref() { + _ if perms.is_empty() => { + catalog.remove(b"Perms"); + } + Some(r) => doc.set(r, Object::Dict(perms)), + None => catalog.set(b"Perms".to_vec(), Object::Dict(perms)), + } + true +} + +/// The layout a document already carries from an earlier pass, if any. +pub fn existing_layout(doc: &Document) -> Option { + let root = doc.root()?; + let catalog = doc.get(root); + let acro = doc.resolve(catalog.as_dict()?.get(b"AcroForm")?); + let mark = doc.resolve(acro.as_dict()?.get(LAYOUT_KEY)?); + let mark = mark.as_dict()?; + let warnings = mark + .get(b"Warnings") + .map(|w| doc.resolve(w)) + .and_then(|w| w.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|w| w.as_string().map(|s| s.to_text())) + .take(100) + .collect(); + Some(crate::Report { + pages: mark.int(b"Pages").unwrap_or(0).clamp(0, 100_000) as usize, + fields: mark.int(b"Fields").unwrap_or(0).clamp(0, 1_000_000) as usize, + template_bytes: 0, + warnings, + }) +}