mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 17:31:35 +00:00
- 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.
1546 lines
74 KiB
Rust
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));
|
|
}
|
|
}
|