Files
pdfcraft/crates/xfa/src/pdf.rs
T
bflatastic 28585c01a5 Fit the landed changes together
- engine: the on_changes test's XfaRun literals set highlight; the script
  runner reads its template before borrowing the live form for to_js; has_event
  reads the paged live form, as the rest of the runner does, so focus scripts
  find a page-master copy by its own SOM path.
- js: property reads compare and copy the shared (Arc<str>) node text, and the
  property tests build XHost with its current signature.
- xfa: the Emitter test literal sets groups and used; a field split that is
  undone also restores the names handed out; rustfmt on a merged import.
- parity: drop the use/usehref gap from form.xfa-dynamic, as prototypes are now
  resolved before the template is parsed.
2026-11-09 18:46:50 +07:00

1546 lines
74 KiB
Rust

//! Turn laid-out pages into PDF objects inside an open document: page dictionaries with content
//! streams (standard-14 fonts, WinAnsi), image XObjects and AcroForm widgets. Widget appearance
//! streams are left empty here; the forms layer regenerates them from the field dictionaries.
use std::collections::{BTreeMap, HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{literal, win_ansi};
use crate::XfaError;
use crate::layout::{Action, BorderShape, Form, Item, Page, Rounded, Turned, Widget, WidgetKind};
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, js_string, n, parse_hex32, rgb, rgb_array, standard_font, state_name,
};
pub(crate) use strip::USAGE_RIGHTS_REMOVED;
use strip::drop_usage_rights;
use strip::earlier_groups;
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
/// laid out again.
pub const LAYOUT_KEY: &[u8] = b"PCXfaLayout";
/// Private key on generated push buttons whose template has a click script: the button has no
/// PDF action (the script is XFA's, not Acrobat JavaScript); PdfCraft runs the script by the
/// field's SOM path.
pub const CLICK_KEY: &[u8] = b"PCXfaClick";
/// Private key on generated date fields: text the date pattern doesn't read is kept, and shown,
/// as typed, as an XFA form keeps it (the forms layer reads this key).
pub const DATE_KEY: &[u8] = b"PCXfaDate";
/// Private key on the fields generated to name a signature field by its SOM path, one for each
/// step of the path above it (`form1[0]`, then `page1[0]`): a new layout replaces them with the
/// fields they hold.
pub const NODE_KEY: &[u8] = b"PCXfaNode";
/// Private key on each page laying the form out writes: the name of the page area the page was
/// made from. The n-th page with a name is that area's `[n-1]` instance, which scripts address
/// even when no field on the page is left to show it (see `page_area_uses`).
pub const AREA_KEY: &[u8] = b"PCXfaArea";
pub struct Written {
pub pages: usize,
pub fields: usize,
pub warnings: Vec<String>,
/// Usage rights were taken off the catalog (laying the form out voided them).
pub usage_rights: bool,
}
/// On a push button generated for an XFA image field: clicking it picks a picture (the forms
/// layer reads this key).
pub const IMAGE_FIELD_KEY: &[u8] = b"PCXfaImage";
/// On an optional content group PdfCraft made for what a form shows only on screen (`/Screen`)
/// or only in print (`/Print`): a later layout of the form finds and reuses it.
pub const RELEVANT_KEY: &[u8] = b"PCXfaRelevant";
/// The optional content groups of [`RELEVANT_KEY`], by index: what the key says, the group's
/// resource name on the pages, its `/Name`, and its view and print states.
const GROUPS: [(&str, &str, &str, &str, &str); 2] =
[("Screen", "PCXfaScreen", "Form: screen only", "ON", "OFF"), ("Print", "PCXfaPrint", "Form: print only", "OFF", "ON")];
/// On a check box or radio button generated for a round XFA check button (`shape="round"`): its
/// background and border are drawn as a circle, not the widget's rectangle (the forms layer
/// reads this key).
pub const ROUND_KEY: &[u8] = b"PCXfaRound";
/// On an image XObject written for a picture: the SHA-256 (hex) of the picture's bytes. A
/// re-layout (after every script event) finds the XObjects on the pages it replaces by this,
/// and reuses them instead of decoding and embedding the same picture again. Nothing is kept
/// in the catalog, and a match is by the picture's own hash, never by a map read from the file.
const IMAGE_SHA_KEY: &[u8] = b"PCXfaSHA256";
/// Most pages searched for earlier pictures.
const MAX_PAGES_SEARCHED: usize = 10_000;
/// Most pictures remembered from earlier passes.
const MAX_REMEMBERED_IMAGES: usize = 1000;
struct Emitter<'a> {
doc: &'a mut Document,
/// Image XObjects by content hash: those from earlier passes, then this one's.
images: HashMap<[u8; 32], ObjRef>,
fields: Vec<ObjRef>,
radio_groups: HashMap<String, (ObjRef, Vec<ObjRef>)>,
/// The on state selected in each radio group, from the data.
radio_selected: HashMap<String, String>,
field_count: usize,
warnings: Vec<String>,
/// The signatures the file holds, by the SOM path of the field signed.
signed: HashMap<String, Signed>,
/// The screen-only and print-only optional content groups ([`GROUPS`]): an earlier layout's,
/// or made when first needed.
groups: [Option<ObjRef>; 2],
/// Which of [`Self::groups`] this layout drew in.
used: [bool; 2],
/// The full names of the fields the file came with, which a generated name must not repeat.
taken: HashSet<String>,
/// The fields above the signature fields named by their SOM paths, by the path they name:
/// each one's object and the fields it holds.
nodes: HashMap<String, (ObjRef, Vec<ObjRef>)>,
}
/// A signature field of the file that has been signed.
struct Signed {
/// Its `V`: the signature dictionary (a reference, as the file has it).
value: Object,
/// Its widget's `AP`: what the signer's application drew in the box.
appearance: Option<Object>,
}
/// Most fields looked at for signatures, counted as they are queued.
const MAX_FIELDS_SEARCHED: usize = 20_000;
/// Deepest field nesting followed.
const MAX_FIELD_DEPTH: usize = 32;
/// Most `/Fields` and `/Kids` entries read while looking, repeats included.
const MAX_KIDS_READ: usize = 1 << 20;
/// The fields the document came with: the full name of each, and the signed signature fields by
/// SOM path. Signing applications name a dynamic form's fields by their SOM path
/// (`form1[0].page1[0].sign[0]`), which is how a laid-out signature box finds its signature. A
/// field an earlier layout pass generated gives no name, but a signature on it is kept, by its
/// `/PCSom`, so a new layout keeps the box signed.
fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap<String, Signed>, HashSet<String>) {
let mut out = HashMap::new();
let mut names = HashSet::new();
let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return (out, names) };
let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return (out, names) };
let mut walk = FieldQueue::default();
// The accumulated name is shared by the children it is queued for, not copied into each.
let top: std::rc::Rc<str> = "".into();
walk.queue(fields.as_array().map(Vec::as_slice).unwrap_or_default(), &top, false, 0);
while let Some((field, prefix, parent_is_sig, depth)) = walk.stack.pop() {
let field = doc.get(field);
let Some(d) = field.as_dict() else { continue };
if d.contains(NODE_KEY) {
// Generated to name a signature field: only what it holds is looked at.
if depth < MAX_FIELD_DEPTH {
let kids = d.get(b"Kids").map(|k| doc.resolve(k));
walk.queue(kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), &top, false, depth + 1);
}
continue;
}
if d.contains(SOM_KEY) {
let som = d.get(SOM_KEY).map(|s| doc.resolve(s)).and_then(|s| s.as_string().map(|s| s.to_text()));
if d.name(b"FT") == Some(b"Sig")
&& let Some(som) = som
&& let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some())
{
out.entry(som).or_insert(Signed { value: value.clone(), appearance: d.get(b"AP").cloned() });
}
continue;
}
let name = match d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())) {
Some(t) if prefix.is_empty() => t.into(),
Some(t) => format!("{prefix}.{t}").into(),
None => prefix,
};
if !name.is_empty() {
names.insert(name.to_string());
}
let is_sig = match d.name(b"FT") {
Some(ft) => ft == b"Sig",
None => parent_is_sig,
};
let kids = d.get(b"Kids").map(|k| doc.resolve(k));
let kids = kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default();
if is_sig && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) {
// The appearance is on the field when it is its own widget, or on its one widget.
let appearance = d.get(b"AP").cloned().or_else(|| walk.find_map(kids, |k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned())));
out.entry(name.to_string()).or_insert(Signed { value: value.clone(), appearance });
}
if depth < MAX_FIELD_DEPTH {
walk.queue(kids, &name, is_sig, depth + 1);
}
}
(out, names)
}
/// Required (`Ff` bit 2), which viewers outline when they highlight a form's fields, for widget
/// `w` when it must be filled in. A radio button's flags are its group's (the group takes it,
/// [`Emitter::radio_group`]), and a push button keeps no value (ISO 32000-2 §12.7.5.2.2), so
/// there is nothing it could be required to hold.
fn required_flag(w: &Widget) -> i64 {
if w.required && !matches!(w.kind, WidgetKind::Radio { .. } | WidgetKind::Button { .. }) { 1 << 1 } else { 0 }
}
/// The fields [`existing_fields`] still has to look at: each with its parent's full name,
/// whether it inherits a signature type, and its depth.
#[derive(Default)]
struct FieldQueue {
stack: Vec<(ObjRef, std::rc::Rc<str>, bool, usize)>,
/// The shallowest depth each object number was queued at: [`Document::get`] finds a field
/// by number alone, so `5 0 R` and `5 1 R` are the same field.
seen: HashMap<u32, usize>,
/// Entries pushed onto `stack`, a field queued again at a shallower depth included.
pushed: usize,
/// `/Fields` and `/Kids` entries read while queuing.
read: usize,
/// `/Kids` entries read while searching for a signed field's appearance.
searched: usize,
}
impl FieldQueue {
/// The first of `kids` for which `f` gives a value. At most [`MAX_KIDS_READ`] entries are
/// looked at in all: signed fields sharing one long `/Kids` would otherwise each search all
/// of it. The searches have a budget of their own, apart from the one for queuing, so a
/// search can never leave a field's kids unqueued. Past the limit nothing more is looked at.
fn find_map<T>(&mut self, kids: &[Object], mut f: impl FnMut(&Object) -> Option<T>) -> Option<T> {
for k in kids {
self.searched += 1;
if self.searched > MAX_KIDS_READ {
return None;
}
if let Some(v) = f(k) {
return Some(v);
}
}
None
}
/// Queue the fields `kids` lists, each the first time it is listed. Fields and kids are
/// indirect references (ISO 32000-2 §12.7.3.1); anything else is skipped. A field listed
/// again, by this list or another, under any generation, takes no room, unless it is now
/// shallower than where it was queued: first reached at [`MAX_FIELD_DEPTH`], its kids would
/// otherwise never be read. So a field is queued at most once per depth, and at most
/// [`MAX_FIELDS_SEARCHED`] entries are ever queued: a field listing one kid many times, or
/// fields sharing one long `/Kids`, would otherwise queue it all on every visit. Reading the
/// repeats costs too, so at most [`MAX_KIDS_READ`] entries are read in all.
fn queue(&mut self, kids: &[Object], prefix: &std::rc::Rc<str>, is_sig: bool, depth: usize) {
for k in kids {
self.read += 1;
if self.pushed >= MAX_FIELDS_SEARCHED || self.read > MAX_KIDS_READ {
return;
}
let Some(r) = k.as_ref() else { continue };
if self.seen.get(&r.num).is_none_or(|&queued| depth < queued) {
self.seen.insert(r.num, depth);
self.pushed += 1;
self.stack.push((r, std::rc::Rc::clone(prefix), is_sig, depth));
}
}
}
}
impl Emitter<'_> {
fn da(face: &Face) -> Vec<u8> {
format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes()
}
/// The field of radio group `group`, which button `w` is a kid of: the one an earlier button
/// made (required too when `w` must be chosen), or a new field of the form, with `w`'s text
/// look and the group's SOM path (the button's without its last step).
fn radio_group(&mut self, w: &Widget, group: &str) -> Result<ObjRef, XfaError> {
if let Some((p, _)) = self.radio_groups.get(group) {
let p = *p;
// A later button that must be chosen makes the group required too.
if w.required {
self.doc.update_dict(p, |g| {
let ff = g.int(b"Ff").unwrap_or(0);
g.set(b"Ff".to_vec(), Object::Int(ff | (1 << 1)));
})?;
}
return Ok(p);
}
let mut g = Dict::new();
g.set(b"FT".to_vec(), Object::name("Btn"));
let required = if w.required { 1 << 1 } else { 0 };
g.set(b"Ff".to_vec(), Object::Int((1 << 15) | (1 << 14) | required));
g.set(b"T".to_vec(), Object::String(PdfString::text(group)));
g.set(b"V".to_vec(), Object::name("Off"));
g.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face))));
g.set(b"Kids".to_vec(), Object::Array(Vec::new()));
let group_som = w.som.rsplit_once('.').map(|(g, _)| g.to_string()).unwrap_or_else(|| w.som.clone());
g.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&group_som)));
let p = self.doc.add(Object::Dict(g));
self.radio_groups.insert(group.to_string(), (p, Vec::new()));
self.fields.push(p);
self.field_count += 1;
Ok(p)
}
/// Fill in widget `w`'s field by its kind (field type, flags, value, appearance states, items,
/// action) in `d` and `mk`. Returns its field flags, whether it is a top-level field (not a kid
/// of its radio group's field, nor a signature field under the fields generated for its SOM
/// path), and for such a signature field the SOM path of the generated field above it.
fn populate_widget_kind(&mut self, w: &Widget, d: &mut Dict, mk: &mut Dict) -> Result<(i64, bool, Option<String>), XfaError> {
let r = w.rect;
let mut ff: i64 = 0;
if w.read_only {
ff |= 1;
}
ff |= required_flag(w);
let mut top_level = true;
// The SOM path of the generated field above this one, for a signature field named by its
// SOM path.
let mut under: Option<String> = None;
match &w.kind {
WidgetKind::Text | WidgetKind::Date(_) | WidgetKind::Password => {
d.set(b"FT".to_vec(), Object::name("Tx"));
if w.multiline {
ff |= 1 << 12;
}
if w.kind == WidgetKind::Password {
ff |= 1 << 13;
}
// DoNotScroll (ISO 32000-2 Table 228): the field takes no more than it shows.
if w.no_scroll {
ff |= 1 << 23;
}
if let Some(m) = w.max_chars {
d.set(b"MaxLen".to_vec(), Object::Int(m.min(100_000) as i64));
}
let q = match w.h_align {
crate::model::HAlign::Center => 1,
crate::model::HAlign::Right => 2,
_ => 0,
};
if q != 0 {
d.set(b"Q".to_vec(), Object::Int(q));
}
if let Some(v) = &w.value {
d.set(b"V".to_vec(), Object::String(PdfString::text(v)));
}
if let Some(v) = &w.default {
d.set(b"DV".to_vec(), Object::String(PdfString::text(v)));
}
if let WidgetKind::Date(pattern) = &w.kind {
d.set(DATE_KEY.to_vec(), Object::Bool(true));
let mut aa = Dict::new();
let pattern = js_string(pattern);
aa.set(b"F".to_vec(), js_action(&format!("AFDate_FormatEx({pattern});")));
aa.set(b"K".to_vec(), js_action(&format!("AFDate_KeystrokeEx({pattern});")));
d.set(b"AA".to_vec(), Object::Dict(aa));
}
}
WidgetKind::Choice { options, list_box, multi, editable } => {
d.set(b"FT".to_vec(), Object::name("Ch"));
if !list_box {
ff |= 1 << 17;
if *editable {
ff |= 1 << 18;
}
}
if *multi {
ff |= 1 << 21;
}
let opt: Vec<Object> = options
.iter()
.map(|(saved, shown)| {
if saved == shown {
Object::String(PdfString::text(shown))
} else {
Object::Array(vec![Object::String(PdfString::text(saved)), Object::String(PdfString::text(shown))])
}
})
.collect();
d.set(b"Opt".to_vec(), Object::Array(opt));
let q = match w.h_align {
crate::model::HAlign::Center => 1,
crate::model::HAlign::Right => 2,
_ => 0,
};
if q != 0 {
d.set(b"Q".to_vec(), Object::Int(q));
}
let values = |v: &str| -> Object {
let picked: Vec<&str> = if *multi { v.lines().filter(|l| !l.is_empty()).collect() } else { vec![v] };
match picked.as_slice() {
[one] => Object::String(PdfString::text(one)),
many => Object::Array(many.iter().map(|s| Object::String(PdfString::text(s))).collect()),
}
};
if let Some(v) = &w.value {
d.set(b"V".to_vec(), values(v));
// The selected indices (ISO 32000-2 Table 231): ascending, each once. Where
// two options save the same value the first is meant; the data can't say.
let picked: Vec<&str> = if *multi { v.lines().filter(|l| !l.is_empty()).collect() } else { vec![v.as_str()] };
let mut idx: Vec<usize> = picked.iter().filter_map(|p| options.iter().position(|(saved, _)| saved == p)).collect();
idx.sort_unstable();
idx.dedup();
if !idx.is_empty() {
d.set(b"I".to_vec(), Object::Array(idx.into_iter().map(|i| Object::Int(i as i64)).collect()));
}
}
if let Some(v) = &w.default {
// Already a saved value (the layout maps defaults as it maps data).
d.set(b"DV".to_vec(), values(v));
}
}
WidgetKind::Signature => {
d.set(b"FT".to_vec(), Object::name("Sig"));
// Signed already: the box shows the signature the file holds and is done with.
if let Some(signed) = self.signed.get(&w.som) {
d.set(b"V".to_vec(), signed.value.clone());
if let Some(ap) = &signed.appearance {
d.set(b"AP".to_vec(), ap.clone());
}
ff |= 1;
}
if let Some((above, last)) = self.som_steps(&w.som)
&& let Some(parent) = self.node(&above)
{
// Named by its SOM path, as fields under one for each step above it: other
// viewers lay a dynamic form out again and find a signature by that name.
d.set(b"T".to_vec(), Object::String(PdfString::text(&last)));
d.set(b"Parent".to_vec(), Object::Ref(parent));
top_level = false;
under = Some(above.join("."));
}
}
WidgetKind::Image => {
// A push button with nothing drawn: the forms layer treats one so marked as an
// image field (clicking picks a picture), with no action other viewers run.
d.set(b"FT".to_vec(), Object::name("Btn"));
ff |= 1 << 16;
d.set(IMAGE_FIELD_KEY.to_vec(), Object::Bool(true));
}
WidgetKind::CheckBox { on, .. } => {
d.set(b"FT".to_vec(), Object::name("Btn"));
let on = state_name(on);
let checked = w.value.is_some();
d.set(b"V".to_vec(), Object::name(if checked { &on } else { "Off" }));
d.set(b"AS".to_vec(), Object::name(if checked { &on } else { "Off" }));
if w.default.as_deref().is_some_and(|dv| crate::data::is_on(dv, &on)) {
d.set(b"DV".to_vec(), Object::name(&on));
}
let mut nd = Dict::new();
nd.set(on.into_bytes(), empty_form(self.doc, r.w, r.h));
nd.set(b"Off".to_vec(), empty_form(self.doc, r.w, r.h));
let mut ap = Dict::new();
ap.set(b"N".to_vec(), Object::Dict(nd));
d.set(b"AP".to_vec(), Object::Dict(ap));
d.set(b"PCItems".to_vec(), Object::Array(w.items.iter().map(|i| Object::String(PdfString::text(i))).collect()));
}
WidgetKind::Radio { group, on, .. } => {
top_level = false;
let parent = self.radio_group(w, group)?;
d.set(b"Parent".to_vec(), Object::Ref(parent));
// Appearance states are PDF names, so a space becomes an underscore. The datasets
// still hold the template's text (`/PCOn`); writing the name back would drop the selection.
d.set(b"PCOn".to_vec(), Object::String(PdfString::text(on)));
let state = state_name(on);
let chosen = w.value.is_some();
d.set(b"AS".to_vec(), Object::name(if chosen { &state } else { "Off" }));
if chosen {
self.radio_selected.insert(group.clone(), state.clone());
}
let mut nd = Dict::new();
nd.set(state.into_bytes(), empty_form(self.doc, r.w, r.h));
nd.set(b"Off".to_vec(), empty_form(self.doc, r.w, r.h));
let mut ap = Dict::new();
ap.set(b"N".to_vec(), Object::Dict(nd));
d.set(b"AP".to_vec(), Object::Dict(ap));
d.remove(b"DA");
}
WidgetKind::Button { caption } => {
d.set(b"FT".to_vec(), Object::name("Btn"));
ff |= 1 << 16;
mk.set(b"CA".to_vec(), Object::String(PdfString::text(caption)));
write_button_action(w.action.as_ref(), d);
}
}
Ok((ff, top_level, under))
}
/// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with
/// its border, background and kind, its flags saying whether it shows on screen and in print,
/// and return the annotation's object.
fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result<Option<ObjRef>, XfaError> {
let r = w.rect;
let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y);
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
d.set(b"Subtype".to_vec(), Object::name("Widget"));
// Print (4) when it prints, NoView (32) when it isn't shown on screen, Hidden (2) when
// it is shown nowhere.
let flags = match (w.views.screen, w.views.print) {
(true, true) => 4,
(true, false) => 0,
(false, true) => 4 | 32,
(false, false) => 2,
};
d.set(b"F".to_vec(), Object::Int(flags));
d.set(b"P".to_vec(), Object::Ref(page_ref));
d.set(b"Rect".to_vec(), Object::Array([x0, y0, x1, y1].iter().map(|v| Object::Real(*v)).collect()));
d.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face))));
if let Some(t) = &w.tooltip {
d.set(b"TU".to_vec(), Object::String(PdfString::text(t)));
}
d.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&w.som)));
let mut mk = Dict::new();
if let Some(c) = w.border.color
&& w.border.shape != BorderShape::None
{
mk.set(b"BC".to_vec(), rgb_array(c));
}
if let Some(c) = w.border.fill {
mk.set(b"BG".to_vec(), rgb_array(c));
}
let mut bs = Dict::new();
bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) }));
bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" }));
d.set(b"BS".to_vec(), Object::Dict(bs));
if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) {
d.set(ROUND_KEY.to_vec(), Object::Bool(true));
}
let (ff, top_level, under) = self.populate_widget_kind(w, &mut d, &mut mk)?;
if !mk.is_empty() {
d.set(b"MK".to_vec(), Object::Dict(mk));
}
if ff != 0 {
d.set(b"Ff".to_vec(), Object::Int(ff));
}
if top_level {
d.set(b"T".to_vec(), Object::String(PdfString::text(&w.name)));
}
let r = self.doc.add(Object::Dict(d));
if top_level {
self.fields.push(r);
self.field_count += 1;
} else if let Some(path) = under {
if let Some((_, kids)) = self.nodes.get_mut(&path) {
kids.push(r);
}
self.taken.insert(w.som.clone());
self.field_count += 1;
} else if let WidgetKind::Radio { group, .. } = &w.kind
&& let Some((_, kids)) = self.radio_groups.get_mut(group)
{
kids.push(r);
}
Ok(Some(r))
}
/// The steps of SOM path `som` above the last, and the last, when a field can be named by the
/// path: two to [`MAX_FIELD_DEPTH`] steps, none empty, and no field of the file or of this
/// pass already named by the path or by a part of it. `None` keeps the field's own name.
fn som_steps(&self, som: &str) -> Option<(Vec<String>, String)> {
let steps: Vec<&str> = som.split('.').collect();
if steps.len() < 2 || steps.len() > MAX_FIELD_DEPTH || steps.iter().any(|s| s.is_empty()) {
return None;
}
for i in 1..=steps.len() {
let path = steps.get(..i)?.join(".");
// A part already generated by this pass is shared; one the file has is not touched.
let generated = i < steps.len() && self.nodes.contains_key(&path);
if !generated && self.taken.contains(&path) {
return None;
}
}
let (last, above) = steps.split_last()?;
Some((above.iter().map(|s| s.to_string()).collect(), last.to_string()))
}
/// The generated field named by the SOM path of `steps`, made (with those above it) when this
/// pass has none yet; the topmost goes into the AcroForm's fields. `None` for no steps.
fn node(&mut self, steps: &[String]) -> Option<ObjRef> {
let mut parent: Option<(ObjRef, String)> = None;
for step in steps {
let path = match &parent {
Some((_, above)) => format!("{above}.{step}"),
None => step.clone(),
};
let r = match self.nodes.get(&path) {
Some((r, _)) => *r,
None => {
let mut d = Dict::new();
d.set(b"T".to_vec(), Object::String(PdfString::text(step)));
d.set(b"Kids".to_vec(), Object::Array(Vec::new()));
d.set(NODE_KEY.to_vec(), Object::Bool(true));
if let Some((p, _)) = &parent {
d.set(b"Parent".to_vec(), Object::Ref(*p));
}
let r = self.doc.add(Object::Dict(d));
match parent.as_ref().and_then(|(_, above)| self.nodes.get_mut(above)) {
Some((_, kids)) => kids.push(r),
None => self.fields.push(r),
}
self.nodes.insert(path.clone(), (r, Vec::new()));
self.taken.insert(path.clone());
r
}
};
parent = Some((r, path));
}
parent.map(|(r, _)| r)
}
/// An image XObject for a picture: JPEGs as they are (`DCTDecode`), PNGs, GIFs, BMPs and TIFFs
/// decoded to samples (`FlateDecode`, with a soft mask for transparency). `None`, with a warning,
/// for anything else.
fn image_xobject(&mut self, data: &[u8], content_type: &str) -> Option<ObjRef> {
let hash = content_hash(data);
if let Some(&r) = self.images.get(&hash)
&& let Object::Stream(s) = &*self.doc.get(r)
&& s.dict.name(b"Subtype") == Some(b"Image")
{
return Some(r);
}
let r = self.new_image_xobject(data, content_type, &hash)?;
// Always remembered: each entry is a hash and a reference to an object this pass wrote
// anyway, and a picture past a cap would be decoded and embedded again at every use.
self.images.insert(hash, r);
Some(r)
}
/// Write a new image XObject for a picture, tagged with its `hash`: a JPEG as it is, a PNG
/// or GIF decoded to samples with a soft mask for any transparency. `None`, with a warning,
/// when the picture can't be read or is another kind.
fn new_image_xobject(&mut self, data: &[u8], content_type: &str, hash: &[u8; 32]) -> Option<ObjRef> {
let what = if content_type.is_empty() { "picture" } else { content_type };
let mut d = Dict::new();
d.set(IMAGE_SHA_KEY.to_vec(), Object::String(PdfString::literal(hex32(hash))));
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
match crate::image::kind(data) {
Some(crate::image::Kind::Jpeg) => {
let Some((iw, ih, comps, adobe)) = jpeg_info(data) else {
self.warnings.push(format!("a {what} image is not a readable JPEG and is left out"));
return None;
};
d.set(b"Width".to_vec(), Object::Int(i64::from(iw)));
d.set(b"Height".to_vec(), Object::Int(i64::from(ih)));
d.set(
b"ColorSpace".to_vec(),
Object::name(match comps {
1 => "DeviceGray",
4 => "DeviceCMYK",
_ => "DeviceRGB",
}),
);
if comps == 4 && adobe {
// Adobe APP14 JPEGs store inverted CMYK.
d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0, 1, 0].iter().map(|v| Object::Int(*v)).collect()));
}
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
Some(self.doc.add(Object::Stream(Stream::from_raw(d, data.to_vec()))))
}
Some(crate::image::Kind::Png | crate::image::Kind::Gif | crate::image::Kind::Bmp | crate::image::Kind::Tiff) => {
let img = match crate::image::decode(data) {
Ok(img) => img,
Err(why) => {
self.warnings.push(why);
return None;
}
};
d.set(b"Width".to_vec(), Object::Int(i64::from(img.width)));
d.set(b"Height".to_vec(), Object::Int(i64::from(img.height)));
d.set(b"ColorSpace".to_vec(), Object::name(img.color_space));
if let Some(alpha) = &img.alpha {
let mut m = Dict::new();
m.set(b"Type".to_vec(), Object::name("XObject"));
m.set(b"Subtype".to_vec(), Object::name("Image"));
m.set(b"Width".to_vec(), Object::Int(i64::from(img.width)));
m.set(b"Height".to_vec(), Object::Int(i64::from(img.height)));
m.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
m.set(b"BitsPerComponent".to_vec(), Object::Int(8));
let mr = self.doc.add(Object::Stream(Stream::flate(m, alpha)));
d.set(b"SMask".to_vec(), Object::Ref(mr));
}
Some(self.doc.add(Object::Stream(Stream::flate(d, &img.samples))))
}
None => {
self.warnings.push(format!("a {what} image is not a JPEG, PNG, GIF, BMP or TIFF and is left out"));
None
}
}
}
/// Optional content group `g` of [`GROUPS`] (0 screen only, 1 print only), marked as drawn in:
/// an earlier layout's, or a new one whose `/Usage` says where it shows.
fn group(&mut self, g: usize) -> ObjRef {
if let Some(u) = self.used.get_mut(g) {
*u = true;
}
if let Some(r) = self.groups.get(g).copied().flatten() {
return r;
}
let (key, _, name, view, print) = GROUPS.get(g).copied().unwrap_or(GROUPS[0]);
let state = |event: &str, key: &str, value: &str| {
let mut s = Dict::new();
s.set(key.as_bytes().to_vec(), Object::name(value));
(event.as_bytes().to_vec(), Object::Dict(s))
};
let mut usage = Dict::new();
for (k, v) in [state("View", "ViewState", view), state("Print", "PrintState", print)] {
usage.set(k, v);
}
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("OCG"));
d.set(b"Name".to_vec(), Object::String(PdfString::text(name)));
d.set(b"Usage".to_vec(), Object::Dict(usage));
d.set(RELEVANT_KEY.to_vec(), Object::name(key));
let r = self.doc.add(Object::Dict(d));
if let Some(slot) = self.groups.get_mut(g) {
*slot = Some(r);
}
r
}
/// Write one laid-out page under `pages_ref`: its fills, lines, text and pictures in a
/// content stream, the fonts and images it uses as resources, and its widgets and the links
/// in its text as annotations. Returns the page object.
fn page(&mut self, page: &Page, pages_ref: ObjRef) -> Result<ObjRef, XfaError> {
let page_ref = self.doc.add(Object::Dict(Dict::new()));
let mut content: Vec<u8> = Vec::new();
let mut fonts: BTreeMap<&'static str, &'static str> = BTreeMap::new();
let mut xobjects = Dict::new();
let mut annots = Vec::new();
let h = page.height;
// The group whose marked content is open, and the groups the page uses.
let mut open: Option<usize> = None;
let mut properties = Dict::new();
// The link the text before ended in, which a next piece of it on the line widens.
let mut last_link: Option<LinkPiece> = None;
let steps = steps(&page.items);
let mut turn = None;
for (i, step) in steps.iter().enumerate() {
let item = match step {
Step::Item(item) => *item,
// A turned draw's `q`/`Q` and the marked content nest, never cross: an open group
// is closed first, and opened again inside or after as its items need.
Step::Turn(t) => {
if open.take().is_some() {
content.extend_from_slice(b"EMC\n");
}
let matrix = turn_matrix(t, h);
turn = Some(matrix);
last_link = None;
content.extend(format!("q {} cm\n", matrix.map(n).join(" ")).bytes());
continue;
}
Step::EndTurn => {
if open.take().is_some() {
content.extend_from_slice(b"EMC\n");
}
turn = None;
last_link = None;
content.extend_from_slice(b"Q\n");
continue;
}
};
let (group, item) = match item {
Item::Only { views, item } => (Some(if views.screen { 0 } else { 1 }), item.as_ref()),
other => (None, other),
};
// A field's widget is an annotation, not page content: the marked content runs on.
if !matches!(item, Item::Widget(_)) && group != open {
if open.is_some() {
content.extend_from_slice(b"EMC\n");
}
if let Some((g, (_, resource, ..))) = group.and_then(|g| GROUPS.get(g).map(|k| (g, k))) {
properties.set(resource.as_bytes().to_vec(), Object::Ref(self.group(g)));
content.extend(format!("/OC /{resource} BDC\n").bytes());
}
open = group;
// A link's pieces join only within one group: what is in another shows elsewhere.
last_link = None;
}
// A link's pieces join only across text and the underline drawn just before it: a fill,
// picture, field or any other line (a rule) between them ends the link there.
let keeps_link = match item {
Item::Text(_) => true,
Item::Line { from, to, .. } => matches!(steps.get(i + 1).and_then(Step::item).map(bare), Some(Item::Text(s))
if (from.1 - to.1).abs() < 0.01
&& (from.0 - s.x).abs() < 0.01
&& (from.1 - (s.baseline + s.face.size * crate::layout::UNDERLINE_DROP)).abs() < 0.01),
_ => false,
};
if !keeps_link {
last_link = None;
}
match item {
Item::Fill { rect, color } => {
content.extend(format!("{} rg {} {} {} {} re f\n", rgb(*color), n(rect.x), n(h - rect.bottom()), n(rect.w), n(rect.h)).bytes());
}
Item::Rounded(r) => content.extend(rounded(r, h).bytes()),
Item::Line { from, to, width, color, dashed } => {
content.extend(
format!(
"q {} RG {} w {}{} {} m {} {} l S Q\n",
rgb(*color),
n(*width),
if *dashed { "[2 2] 0 d " } else { "" },
n(from.0),
n(h - from.1),
n(to.0),
n(h - to.1)
)
.bytes(),
);
}
Item::Text(s) => {
// The form's own text, set in the standard fonts: what they can't draw is
// shown as `?` and reported once (the layout goes on), never silently.
if let Some(c) = pdfcraft_fonts::first_undrawable(&s.text) {
const PREFIX: &str = "some of the form's text has characters the standard fonts can't draw";
if !self.warnings.iter().any(|w| w.starts_with(PREFIX)) {
self.warnings.push(format!(
"{PREFIX} (the first is \"{}\" (U+{:04X})); they are shown as ? until PdfCraft can embed fonts for them",
c.escape_debug(),
u32::from(c)
));
}
}
let name = s.face.resource_name();
fonts.insert(name, s.face.family.base_font(s.face.bold, s.face.italic));
// Horizontal scaling is text state, which outlives the text object: a
// condensed span gets a graphics state of its own, so the next isn't.
let scaled = (s.face.h_scale - 1.0).abs() > 1e-6;
let (open, tz, close) =
if scaled { ("q BT", format!(" {} Tz", n(s.face.h_scale * 100.0)), " ET Q") } else { ("BT", String::new(), " ET") };
content.extend(
format!("{open} /{name} {} Tf {} rg{tz} 1 0 0 1 {} {} Tm ", n(s.face.size), rgb(s.face.color), n(s.x), n(h - s.baseline))
.bytes(),
);
content.extend(literal(&win_ansi(&s.text)));
content.extend_from_slice(format!(" Tj{close}\n").as_bytes());
if let Some(r) = self.text_link(s, h, page_ref, &mut last_link, turn)? {
// A link works only where its text shows: one in print-only text is
// NoView (32), so it can't be clicked on screen, and one in screen-only
// text doesn't print, as a field's widget there.
if let Some(g) = group {
self.doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(if g == 0 { 0 } else { 4 | 32 })))?;
}
annots.push(Object::Ref(r));
}
}
Item::Image { rect, data, content_type } => {
let Some(r) = self.image_xobject(data, content_type) else { continue };
let name = format!("Im{i}");
xobjects.set(name.clone().into_bytes(), Object::Ref(r));
content.extend(format!("q {} 0 0 {} {} {} cm /{name} Do Q\n", n(rect.w), n(rect.h), n(rect.x), n(h - rect.bottom())).bytes());
}
// Opened out by `steps`.
Item::Turned(_) => {}
Item::Widget(w) => {
if let Some(r) = self.widget(w, page, page_ref)? {
annots.push(Object::Ref(r));
}
}
// Taken apart above: an item is never wrapped twice.
Item::Only { .. } => {}
}
}
if open.is_some() {
content.extend_from_slice(b"EMC\n");
}
let mut font_dict = Dict::new();
for (name, base) in fonts {
font_dict.set(name.as_bytes().to_vec(), standard_font(base));
}
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(font_dict));
if !xobjects.is_empty() {
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
}
if !properties.is_empty() {
res.set(b"Properties".to_vec(), Object::Dict(properties));
}
let mut cd = Dict::new();
cd.set(b"Type".to_vec(), Object::name("XObject"));
let contents = self.doc.add(Object::Stream(Stream::flate(Dict::new(), &content)));
let _ = cd;
let mut pd = Dict::new();
pd.set(b"Type".to_vec(), Object::name("Page"));
pd.set(b"Parent".to_vec(), Object::Ref(pages_ref));
pd.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, page.width, page.height].iter().map(|v| Object::Real(*v)).collect()));
pd.set(b"Resources".to_vec(), Object::Dict(res));
pd.set(b"Contents".to_vec(), Object::Ref(contents));
if let Some(area) = &page.area {
pd.set(AREA_KEY.to_vec(), Object::String(PdfString::text(area)));
}
if !annots.is_empty() {
pd.set(b"Annots".to_vec(), Object::Array(annots));
}
self.doc.set(page_ref, Object::Dict(pd));
Ok(page_ref)
}
/// A link annotation over span `s` (on a page `page_height` tall), when its text links
/// somewhere: over the text it draws, without the spaces around it, and opening its address.
/// A span that goes on the link `last` ended in, on the same line, widens that annotation
/// instead (a link whose text changes style partway is one link). An address PdfCraft
/// wouldn't open from a document leaves the text as plain text, with one warning for the form.
fn text_link(
&mut self,
s: &Span,
page_height: f64,
page_ref: ObjRef,
last: &mut Option<LinkPiece>,
turn: Option<[f64; 6]>,
) -> Result<Option<ObjRef>, XfaError> {
let piece = self.link_piece(s, page_height);
let Some((target, mut rect)) = piece else {
*last = None;
return Ok(None);
};
// Turned text is drawn by a matrix, and clicked where that puts it. Its pieces don't join:
// that goes by the line they stand on before turning.
if let Some(m) = turn {
rect = turned_rect(rect, m);
*last = None;
}
if let Some(prev) = last.as_mut()
&& prev.uri == target
&& (prev.baseline - s.baseline).abs() < 0.01
&& (-0.01..=s.face.size).contains(&(rect[0] - prev.rect[2]))
{
prev.rect = [prev.rect[0].min(rect[0]), prev.rect[1].min(rect[1]), prev.rect[2].max(rect[2]), prev.rect[3].max(rect[3])];
let r = prev.rect;
self.doc.update_dict(prev.annot, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?;
return Ok(None);
}
let mut action = Dict::new();
action.set(b"S".to_vec(), Object::name("URI"));
action.set(b"URI".to_vec(), Object::String(PdfString::literal(target.as_bytes().to_vec())));
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
d.set(b"Subtype".to_vec(), Object::name("Link"));
d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect()));
d.set(b"P".to_vec(), Object::Ref(page_ref));
d.set(b"F".to_vec(), Object::Int(4));
d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(0)]));
d.set(b"A".to_vec(), Object::Dict(action));
let annot = self.doc.add(Object::Dict(d));
*last = Some(LinkPiece { annot, uri: target, baseline: s.baseline, rect });
Ok(Some(annot))
}
/// Where span `s` links to and the rectangle (PDF user space, on a page `page_height` tall)
/// over the text it draws, without the spaces around it. `None` for text that links nowhere,
/// draws nothing, or links to an address PdfCraft wouldn't open from a document, which is
/// warned about once for the form.
fn link_piece(&mut self, s: &Span, page_height: f64) -> Option<(String, [f64; 4])> {
let href = s.link.as_deref()?;
let shown = s.text.trim();
if shown.is_empty() {
return None;
}
let Some(target) = link_target(href) else {
if !self.warnings.iter().any(|w| w.starts_with(LINK_LEFT_OUT)) {
self.warnings.push(format!("{LINK_LEFT_OUT}: PdfCraft makes links only of web (http, https) and email (mailto) addresses"));
}
return None;
};
let lead = s.text.len() - s.text.trim_start().len();
let x = s.x + s.face.width(s.text.get(..lead).unwrap_or_default());
let w = s.face.width(shown);
// The text's ascent and descent, as the layout measures a span's extent.
let rect = pdf_rect(Rect::new(x, s.baseline - s.face.size * 0.8, w, s.face.size), page_height);
(rect.iter().all(|v| v.is_finite()) && w > 0.0).then(|| (target.to_string(), rect))
}
}
fn write_button_action(action: Option<&Action>, d: &mut Dict) {
match action {
Some(Action::Script(_)) => {
// An XFA click script is no Acrobat JavaScript: no PDF action, which other
// viewers would run; PdfCraft finds the script by the field's SOM path.
d.set(CLICK_KEY.to_vec(), Object::Bool(true));
}
Some(action) => {
let mut ad = Dict::new();
match action {
Action::Reset => {
ad.set(b"S".to_vec(), Object::name("ResetForm"));
}
Action::Print => {
ad.set(b"S".to_vec(), Object::name("Named"));
ad.set(b"N".to_vec(), Object::name("Print"));
}
Action::SaveAs => {
ad.set(b"S".to_vec(), Object::name("Named"));
ad.set(b"N".to_vec(), Object::name("SaveAs"));
}
Action::Url(url) => {
ad.set(b"S".to_vec(), Object::name("URI"));
ad.set(b"URI".to_vec(), Object::String(PdfString::literal(url.as_bytes().to_vec())));
}
Action::Script(_) => {}
}
d.set(b"A".to_vec(), Object::Dict(ad));
}
None => {}
}
}
/// `item`, or what it holds when it is shown only on screen or only in print ([`Item::Only`]).
fn bare(item: &Item) -> &Item {
match item {
Item::Only { item, .. } => item,
other => other,
}
}
/// What a page's content is written from: its items, with a turned draw opened out.
enum Step<'a> {
Item(&'a Item),
Turn(&'a Turned),
EndTurn,
}
impl<'a> Step<'a> {
/// The item, when the step is one.
fn item(&self) -> Option<&'a Item> {
match self {
Step::Item(item) => Some(item),
_ => None,
}
}
}
fn steps(items: &[Item]) -> Vec<Step<'_>> {
let mut out = Vec::with_capacity(items.len());
for item in items {
if let Item::Turned(t) = item {
out.push(Step::Turn(t));
out.extend(t.items.iter().map(Step::Item));
out.push(Step::EndTurn);
} else {
out.push(Step::Item(item));
}
}
out
}
/// The matrix that turns `t` about its point, on a page `page_height` tall.
fn turn_matrix(t: &Turned, page_height: f64) -> [f64; 6] {
let (cos, sin) = match t.quarters {
1 => (0.0, 1.0),
2 => (-1.0, 0.0),
_ => (0.0, -1.0),
};
let (x, y) = (t.about.0, page_height - t.about.1);
[cos, sin, -sin, cos, x - cos * x + sin * y, y - sin * x - cos * y]
}
/// Rectangle `r`, `[left, bottom, right, top]`, under quarter-turn matrix `m`.
fn turned_rect(r: [f64; 4], m: [f64; 6]) -> [f64; 4] {
let at = |x: f64, y: f64| (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
let (a, b) = (at(r[0], r[1]), at(r[2], r[3]));
[a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)]
}
/// The link annotation a page's text last ended in: the next piece of the same link on the same
/// line widens it.
struct LinkPiece {
annot: ObjRef,
uri: String,
baseline: f64,
rect: [f64; 4],
}
/// How the layout warning about a link left out starts.
const LINK_LEFT_OUT: &str = "a link in the form's text was left as plain text";
/// The longest link address kept, in characters: as long as PdfCraft opens from a document.
const MAX_LINK: usize = 2048;
/// `href` as the address of a link PdfCraft makes from a form's text: a web (http, https) or
/// email (mailto) address, trimmed, of at most [`MAX_LINK`] characters and without control
/// characters. `None` for anything else (scripts, files, data, relative paths). Opening it still
/// goes through the app's own checks and prompt.
fn link_target(href: &str) -> Option<&str> {
let href = href.trim();
if href.is_empty() || href.chars().count() > MAX_LINK || href.chars().any(char::is_control) {
return None;
}
let (scheme, rest) = href.split_once(':')?;
let web = scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https");
let ok = if web { rest.strip_prefix("//").is_some_and(has_host) } else { scheme.eq_ignore_ascii_case("mailto") && !rest.is_empty() };
ok.then_some(href)
}
/// Whether `after` (a web address past its `//`) names a host: what comes before the path (which a
/// backslash starts too, as web addresses read it), query or fragment, past any user name and
/// before any port, isn't empty.
fn has_host(after: &str) -> bool {
let authority = after.split(['/', '\\', '?', '#']).next().unwrap_or_default();
let host = authority.rsplit('@').next().unwrap_or_default();
// An IPv6 host is bracketed and holds colons of its own.
let host = if host.starts_with('[') { host } else { host.split(':').next().unwrap_or_default() };
!host.is_empty()
}
/// Replace the document's pages with the laid-out form and add its fields to the AcroForm. The
/// XFA packets and everything else in the catalog stay.
/// The image XObjects earlier passes wrote for pictures, by the SHA-256 each records: those on
/// the pages under `pages` (the flat page list [`write_form`] writes).
fn earlier_images(doc: &Document, pages: ObjRef) -> HashMap<[u8; 32], ObjRef> {
let mut out = HashMap::new();
let kids = doc.get(pages).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k))).and_then(|k| k.as_array().cloned()).unwrap_or_default();
for page in kids.iter().filter_map(Object::as_ref).take(MAX_PAGES_SEARCHED) {
let page = doc.get(page);
let Some(resources) = page.as_dict().and_then(|p| p.get(b"Resources")).map(|r| doc.resolve(r)) else { continue };
let Some(xobjects) = resources.as_dict().and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)) else { continue };
for r in xobjects.as_dict().into_iter().flat_map(|x| x.iter()).filter_map(|(_, v)| v.as_ref()) {
if out.len() >= MAX_REMEMBERED_IMAGES {
return out;
}
if let Object::Stream(s) = &*doc.get(r)
&& s.dict.name(b"Subtype") == Some(b"Image")
&& let Some(hash) = s.dict.get(IMAGE_SHA_KEY).and_then(Object::as_string).and_then(|h| parse_hex32(&h.bytes))
{
out.insert(hash, r);
}
}
}
out
}
/// Write laid-out `form` into `doc`: its pages replace the document's, its widgets join the
/// AcroForm's fields (a box the file holds a signature for keeps it), and the AcroForm is marked
/// laid out. What was written, with any warnings, comes back.
pub fn write_form(doc: &mut Document, form: &Form) -> Result<Written, XfaError> {
let catalog_ref = doc.root().ok_or_else(|| XfaError::Malformed("the document has no catalog".into()))?;
let mut catalog = doc.get(catalog_ref).as_dict().cloned().ok_or_else(|| XfaError::Malformed("the catalog is not a dictionary".into()))?;
let mut catalog_changed = false;
let pages_ref = match catalog.get(b"Pages").and_then(Object::as_ref) {
Some(r) if doc.get(r).as_dict().is_some() => r,
_ => {
let mut p = Dict::new();
p.set(b"Type".to_vec(), Object::name("Pages"));
let r = doc.add(Object::Dict(p));
catalog.set(b"Pages".to_vec(), Object::Ref(r));
catalog_changed = true;
r
}
};
// Pictures embedded by earlier passes, from the pages this pass replaces.
let images = earlier_images(doc, pages_ref);
let (signed, taken) = existing_fields(doc, &catalog);
let groups = earlier_groups(doc, &catalog);
let mut em = Emitter {
doc,
images,
fields: Vec::new(),
radio_groups: HashMap::new(),
radio_selected: HashMap::new(),
field_count: 0,
warnings: Vec::new(),
signed,
groups,
used: [false; 2],
taken,
nodes: HashMap::new(),
};
// Every name a field of this pass takes at the top, wherever and in whatever order it is
// written: a signature field named by its SOM path must not repeat one.
for w in form.pages.iter().flat_map(|p| &p.items).filter_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }) {
em.taken.insert(match &w.kind {
WidgetKind::Radio { group, .. } => group.clone(),
_ => w.name.clone(),
});
}
let mut kids = Vec::new();
for page in &form.pages {
kids.push(Object::Ref(em.page(page, pages_ref)?));
}
let groups: Vec<(String, (ObjRef, Vec<ObjRef>))> = em.radio_groups.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (name, (parent, widgets)) in groups {
let selected = em.radio_selected.get(&name).cloned();
em.doc.update_dict(parent, |d| {
d.set(b"Kids".to_vec(), Object::Array(widgets.iter().map(|r| Object::Ref(*r)).collect()));
if let Some(s) = &selected {
d.set(b"V".to_vec(), Object::name(s));
}
})?;
}
let nodes: Vec<(ObjRef, Vec<ObjRef>)> = em.nodes.values().cloned().collect();
for (node, kids) in nodes {
em.doc.update_dict(node, |d| d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())))?;
}
let Emitter { doc, fields, field_count, warnings, groups, used, .. } = em;
let drawn: Vec<ObjRef> = groups.iter().zip(used).filter(|(_, u)| *u).filter_map(|(g, _)| *g).collect();
if !drawn.is_empty() {
catalog_changed |= register_groups(doc, &mut catalog, &groups, used)?;
}
let count = kids.len();
doc.update_dict(pages_ref, |d| {
d.set(b"Type".to_vec(), Object::name("Pages"));
d.set(b"Kids".to_vec(), Object::Array(kids));
d.set(b"Count".to_vec(), Object::Int(count as i64));
d.remove(b"Parent");
})?;
// AcroForm: keep what is there (the XFA packets above all), add our fields and fonts.
let acro_ref = catalog.get(b"AcroForm").and_then(Object::as_ref);
let mut acro = match catalog.get(b"AcroForm") {
Some(o) => doc.resolve(o).as_dict().cloned().unwrap_or_default(),
None => Dict::new(),
};
let mut all: Vec<Object> = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
all.extend(fields.iter().map(|r| Object::Ref(*r)));
acro.set(b"Fields".to_vec(), Object::Array(all));
let mut dr = acro.get(b"DR").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default();
let mut dr_fonts = dr.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
for (name, base) in all_faces() {
if !dr_fonts.contains(name.as_bytes()) {
dr_fonts.set(name.as_bytes().to_vec(), standard_font(base));
}
}
if !dr_fonts.contains(b"ZaDb") {
let mut z = Dict::new();
z.set(b"Type".to_vec(), Object::name("Font"));
z.set(b"Subtype".to_vec(), Object::name("Type1"));
z.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats"));
dr_fonts.set(b"ZaDb".to_vec(), Object::Dict(z));
}
// Each font its own object: Acrobat shows nothing in a text field it redraws after typing
// when the field's /DR font is a dictionary written inline (#3441).
let inline: Vec<(Vec<u8>, Dict)> = dr_fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect();
for (name, font) in inline {
let r = doc.add(Object::Dict(font));
dr_fonts.set(name, Object::Ref(r));
}
dr.set(b"Font".to_vec(), Object::Dict(dr_fonts));
acro.set(b"DR".to_vec(), Object::Dict(dr));
if !acro.contains(b"DA") {
acro.set(b"DA".to_vec(), Object::String(PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())));
}
let mut mark = Dict::new();
mark.set(b"Pages".to_vec(), Object::Int(count as i64));
mark.set(b"Fields".to_vec(), Object::Int(field_count as i64));
mark.set(b"Warnings".to_vec(), Object::Array(warnings.iter().map(|w| Object::String(PdfString::text(w))).collect()));
acro.set(LAYOUT_KEY.to_vec(), Object::Dict(mark));
match acro_ref {
Some(r) => doc.set(r, Object::Dict(acro)),
None => {
catalog.set(b"AcroForm".to_vec(), Object::Dict(acro));
catalog_changed = true;
}
}
// The pages and fields are new: usage rights signed over the form as it was are void now.
let usage_rights = drop_usage_rights(doc, &mut catalog);
if catalog_changed || usage_rights {
doc.set(catalog_ref, Object::Dict(catalog));
}
Ok(Written { pages: count, fields: field_count, warnings, usage_rights })
}
/// Add the screen-only and print-only `groups` this layout drew in (`used`) to the document's
/// optional content properties, keeping its own layers as they are: each goes in `/OCGs`; in the
/// default configuration the print-only one starts off (the screen-only one starts on when the
/// base state is off); and both join `/AS` usage applications for the View and Print events (an
/// existing one for that event and category, or a new one), so a viewer shows each where it
/// belongs. Nothing already there is added again. Returns whether `catalog` itself changed (a
/// direct `/OCProperties`); an indirect one is updated where it is.
fn register_groups(doc: &mut Document, catalog: &mut Dict, groups: &[Option<ObjRef>; 2], used: [bool; 2]) -> Result<bool, XfaError> {
let mine: Vec<(usize, ObjRef)> =
groups.iter().zip(used).enumerate().filter(|(_, (_, u))| *u).filter_map(|(i, (g, _))| g.map(|r| (i, r))).collect();
let indirect = catalog.get(b"OCProperties").and_then(Object::as_ref);
let mut props = catalog.get(b"OCProperties").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()).unwrap_or_default();
// A list in `dict` under `key` (direct or not), with `add` appended where missing.
let add_refs = |doc: &mut Document, dict: &mut Dict, key: &[u8], add: &[ObjRef]| -> Result<(), XfaError> {
let at = dict.get(key).and_then(Object::as_ref);
let mut list = dict.get(key).map(|l| doc.resolve(l)).and_then(|l| l.as_array().cloned()).unwrap_or_default();
for r in add {
if !list.iter().any(|o| o.as_ref() == Some(*r)) {
list.push(Object::Ref(*r));
}
}
match at {
Some(a) => doc.set(a, Object::Array(list)),
None => dict.set(key.to_vec(), Object::Array(list)),
}
Ok(())
};
let all: Vec<ObjRef> = mine.iter().map(|(_, r)| *r).collect();
add_refs(doc, &mut props, b"OCGs", &all)?;
let d_at = props.get(b"D").and_then(Object::as_ref);
let mut d = props.get(b"D").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let base_off = d.name(b"BaseState") == Some(&b"OFF"[..]);
for (g, r) in &mine {
match (*g, base_off) {
(1, false) => add_refs(doc, &mut d, b"OFF", &[*r])?,
(0, true) => add_refs(doc, &mut d, b"ON", &[*r])?,
_ => {}
}
}
// The usage applications: one per event, with its own category.
let as_at = d.get(b"AS").and_then(Object::as_ref);
let mut apps = d.get(b"AS").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).unwrap_or_default();
for event in ["View", "Print"] {
let matches = |doc: &Document, o: &Object| {
doc.resolve(o).as_dict().is_some_and(|a| {
a.name(b"Event") == Some(event.as_bytes())
&& a.get(b"Category")
.map(|c| doc.resolve(c))
.and_then(|c| c.as_array().cloned())
.is_some_and(|c| c.len() == 1 && c.first().and_then(Object::as_name).is_some_and(|n| n == event.as_bytes()))
})
};
match apps.iter().position(|o| matches(doc, o)) {
Some(i) => {
let entry = apps.get(i).cloned().unwrap_or(Object::Null);
let mut app = doc.resolve(&entry).as_dict().cloned().unwrap_or_default();
add_refs(doc, &mut app, b"OCGs", &all)?;
match entry.as_ref() {
Some(r) => doc.set(r, Object::Dict(app)),
None => {
if let Some(slot) = apps.get_mut(i) {
*slot = Object::Dict(app);
}
}
}
}
None => {
let mut app = Dict::new();
app.set(b"Event".to_vec(), Object::name(event));
app.set(b"OCGs".to_vec(), Object::Array(all.iter().map(|r| Object::Ref(*r)).collect()));
app.set(b"Category".to_vec(), Object::Array(vec![Object::name(event)]));
apps.push(Object::Dict(app));
}
}
}
match as_at {
Some(a) => doc.set(a, Object::Array(apps)),
None => d.set(b"AS".to_vec(), Object::Array(apps)),
}
match d_at {
Some(a) => doc.set(a, Object::Dict(d)),
None => props.set(b"D".to_vec(), Object::Dict(d)),
}
match indirect {
Some(r) => {
doc.set(r, Object::Dict(props));
Ok(false)
}
None => {
catalog.set(b"OCProperties".to_vec(), Object::Dict(props));
Ok(true)
}
}
}
/// 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]
}
#[cfg(test)]
mod tests {
use super::*;
/// The fields `existing_fields` finds under an AcroForm listing `fields`.
fn names_found(doc: &Document, fields: Vec<Object>) -> Vec<String> {
let (signed, names) = existing(doc, fields);
assert!(signed.is_empty());
let mut names: Vec<String> = names.into_iter().collect();
names.sort();
names
}
fn field(name: &str, kids: Object) -> Object {
let mut d = Dict::new();
d.set(b"T".to_vec(), Object::String(PdfString::literal(name)));
d.set(b"Kids".to_vec(), kids);
Object::Dict(d)
}
#[test]
fn fields_listed_many_times_queue_a_bounded_number_of_kids() {
// A field lists itself 5,000 times: queuing every kid on each of 20,000 visits would
// hold a hundred million entries. It is read once.
let mut doc = Document::new_empty();
let r = doc.add(Object::Null);
doc.set(r, field("a", Object::Array(vec![Object::Ref(r); 5_000])));
assert_eq!(names_found(&doc, vec![Object::Ref(r)]), ["a"]);
// 5,000 fields share one /Kids array listing each of them 200 times. Read once each,
// the fields down one branch still queue the whole array at every level: 32 million
// entries, unless the queue itself is bounded.
let mut doc = Document::new_empty();
let kids = doc.add(Object::Null);
let fields: Vec<Object> = (0..5_000).map(|i| Object::Ref(doc.add(field(&format!("f{i}"), Object::Ref(kids))))).collect();
doc.set(kids, Object::Array(fields.iter().cycle().take(1_000_000).cloned().collect()));
let names = names_found(&doc, fields[..1].to_vec());
assert!(names.contains(&"f0".to_string()) && names.len() <= MAX_FIELDS_SEARCHED, "{} names", names.len());
}
#[test]
fn a_field_listed_many_times_leaves_room_for_the_fields_under_it() {
// The AcroForm lists one field 19,999 times. The copies take no room, so its kids are
// still queued, and the signed one among them is found.
let mut doc = Document::new_empty();
let parent = signed_parent(&mut doc);
let (signed, names) = existing(&doc, vec![Object::Ref(parent); MAX_FIELDS_SEARCHED - 1]);
assert!(signed.contains_key("c.sig"), "{:?}", signed.keys().collect::<Vec<_>>());
assert!(names.contains("c.plain"));
}
#[test]
fn a_field_listed_under_many_generations_is_queued_once() {
// `Document::get` finds a field by object number alone, so `5 0 R` and `5 1 R` are one
// field. Listed 19,999 times, each under another generation, it is still queued once,
// and the signed field under it is found.
let mut doc = Document::new_empty();
let parent = signed_parent(&mut doc);
let fields = (0..MAX_FIELDS_SEARCHED - 1).map(|g| Object::Ref(ObjRef::new(parent.num, u16::try_from(g).unwrap()))).collect();
let (signed, names) = existing(&doc, fields);
assert!(signed.contains_key("c.sig"), "{:?}", signed.keys().collect::<Vec<_>>());
assert!(names.contains("c.plain"));
}
#[test]
fn signed_fields_sharing_one_long_kids_array_search_it_within_the_read_limit() {
// 1,000 signed fields share one `/Kids` array of 100,000 nulls and a widget with an
// appearance at its end. Each searched the whole array for that appearance: with a
// million nulls, a billion lookups (39 s). The search counts against `MAX_KIDS_READ`, so
// the fields read once it is spent keep their value but get no appearance.
let mut doc = Document::new_empty();
let mut widget = Dict::new();
widget.set(b"AP".to_vec(), Object::Dict(Dict::new()));
let mut shared = vec![Object::Null; 100_000];
shared.push(Object::Ref(doc.add(Object::Dict(widget))));
let kids = doc.add(Object::Array(shared));
let fields = (0..1_000)
.map(|i| {
let mut d = Dict::new();
d.set(b"T".to_vec(), Object::String(PdfString::literal(format!("s{i}").as_str())));
d.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
d.set(b"V".to_vec(), Object::Dict(Dict::new()));
d.set(b"Kids".to_vec(), Object::Ref(kids));
Object::Ref(doc.add(Object::Dict(d)))
})
.collect();
let (signed, _) = existing(&doc, fields);
assert_eq!(signed.len(), 1_000);
let with_appearance = signed.values().filter(|s| s.appearance.is_some()).count();
assert!((1..=10).contains(&with_appearance), "{with_appearance} appearances found");
}
#[test]
fn an_appearance_search_leaves_the_kids_of_a_signed_field_queued() {
// A signed field with no appearance of its own lists a signed kid and then
// `MAX_KIDS_READ` nulls. Searching them all for an appearance must not spend what
// queuing its kids has to read, or the signed kid is never found.
let mut doc = Document::new_empty();
let mut kid = Dict::new();
kid.set(b"T".to_vec(), Object::String(PdfString::literal("c")));
kid.set(b"V".to_vec(), Object::Dict(Dict::new()));
let mut kids = vec![Object::Ref(doc.add(Object::Dict(kid)))];
kids.extend(std::iter::repeat_n(Object::Null, MAX_KIDS_READ));
let mut parent = Dict::new();
parent.set(b"T".to_vec(), Object::String(PdfString::literal("p")));
parent.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
parent.set(b"V".to_vec(), Object::Dict(Dict::new()));
parent.set(b"Kids".to_vec(), Object::Array(kids));
let parent = doc.add(Object::Dict(parent));
let (signed, _) = existing(&doc, vec![Object::Ref(parent)]);
assert!(signed.contains_key("p.c"), "{:?}", signed.keys().collect::<Vec<_>>());
}
#[test]
fn a_field_reached_first_at_the_depth_limit_is_read_again_when_reached_shallower() {
// Field `s` is listed at the bottom of a chain `MAX_FIELD_DEPTH` deep, which is walked
// first, and again under `b`. Reached first where its kids are not read, it is read
// again under `b`, and the signed field under it is found.
let mut doc = Document::new_empty();
let mut sig = Dict::new();
sig.set(b"T".to_vec(), Object::String(PdfString::literal("sig")));
sig.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
sig.set(b"V".to_vec(), Object::Dict(Dict::new()));
let sig = doc.add(Object::Dict(sig));
let shared = doc.add(field("s", Object::Array(vec![Object::Ref(sig)])));
let mut deep = shared;
for i in (0..MAX_FIELD_DEPTH).rev() {
deep = doc.add(field(&format!("d{i}"), Object::Array(vec![Object::Ref(deep)])));
}
let b = doc.add(field("b", Object::Array(vec![Object::Ref(shared)])));
let (signed, names) = existing(&doc, vec![Object::Ref(b), Object::Ref(deep)]);
assert!(signed.contains_key("b.s.sig"), "{:?}", signed.keys().collect::<Vec<_>>());
assert!(names.contains("b.s"));
}
/// Field `c` with two kids: `plain`, and `sig`, a signed signature field.
fn signed_parent(doc: &mut Document) -> ObjRef {
let plain = doc.add(field("plain", Object::Array(Vec::new())));
let mut sig = Dict::new();
sig.set(b"T".to_vec(), Object::String(PdfString::literal("sig")));
sig.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec()));
sig.set(b"V".to_vec(), Object::Dict(Dict::new()));
let sig = doc.add(Object::Dict(sig));
doc.add(field("c", Object::Array(vec![Object::Ref(plain), Object::Ref(sig)])))
}
/// What `existing_fields` finds under an AcroForm listing `fields`.
fn existing(doc: &Document, fields: Vec<Object>) -> (HashMap<String, Signed>, HashSet<String>) {
let mut acro = Dict::new();
acro.set(b"Fields".to_vec(), Object::Array(fields));
let mut catalog = Dict::new();
catalog.set(b"AcroForm".to_vec(), Object::Dict(acro));
existing_fields(doc, &catalog)
}
#[test]
fn parse_hex32_rejects_non_hex_digits() {
for prefix in ["+f", "-f", " f"] {
let mut s = prefix.as_bytes().to_vec();
s.resize(64, b'0');
assert!(parse_hex32(&s).is_none(), "{prefix:?}");
}
let mut ok = b"Ff".to_vec();
ok.resize(64, b'0');
assert_eq!(parse_hex32(&ok).map(|h| h[0]), Some(0xff));
}
#[test]
fn a_picture_past_the_earlier_pass_cap_is_still_embedded_once() {
let mut doc = Document::new_empty();
let mut em = Emitter {
doc: &mut doc,
images: HashMap::new(),
fields: Vec::new(),
radio_groups: HashMap::new(),
radio_selected: HashMap::new(),
field_count: 0,
warnings: Vec::new(),
signed: Default::default(),
groups: [None; 2],
used: [false; 2],
taken: Default::default(),
nodes: HashMap::new(),
};
let png = crate::fixtures::tiny_png();
let picture = |i: u32| [png.clone(), i.to_be_bytes().to_vec()].concat();
let last = MAX_REMEMBERED_IMAGES as u32 + 10;
for i in 0..=last {
em.image_xobject(&picture(i), "image/png").expect("decodes");
}
let before = em.doc.object_numbers().len();
let again = em.image_xobject(&picture(last), "image/png").expect("decodes");
assert_eq!(em.doc.object_numbers().len(), before, "no new object for a repeated picture");
assert_eq!(em.image_xobject(&picture(last), "image/png"), Some(again));
}
}