refactor(xfa): split pdf.rs into pdf/

New modules: helpers, strip.
This commit is contained in:
aiongg committed 2026-10-26 22:18:50 +00:00
1 parent 3da49321f2
commit 0988209798
3 files changed
+492 -480

No files matched your search

+10 -480
View File
@@ -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<u8> {
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<Written, XfaError>
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<String>,
/// 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<usize>,
/// 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<String>,
/// 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<String> {
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<XfaShape> {
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<Stripped, XfaError> {
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<ObjRef> = 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<ObjRef> = 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<Vec<u8>> =
[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<usize> {
let data = crate::script::data_of(doc)?;
let tpl = crate::script::template_of(doc).ok()?;
let bound: std::collections::HashSet<crate::data::DataPath> =
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<crate::data::DataPath>,
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<crate::Report> {
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]
+200
View File
@@ -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<u8> {
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)
}
+282
View File
@@ -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<String>,
/// 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<usize>,
/// 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<String>,
/// 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<String> {
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<XfaShape> {
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<Stripped, XfaError> {
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<ObjRef> = 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<ObjRef> = 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<Vec<u8>> =
[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<usize> {
let data = crate::script::data_of(doc)?;
let tpl = crate::script::template_of(doc).ok()?;
let bound: std::collections::HashSet<crate::data::DataPath> =
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<crate::data::DataPath>,
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<crate::Report> {
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,
})
}