mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 19:22:34 +00:00
Nothing read rotate, so a turned line, picture or label was drawn as if it stood upright: part of #5561. XFA gives the angle in degrees counter-clockwise about the anchor, and Acrobat's flattened page for that issue's form puts the three turned lines exactly there, which the test holds it to. A turned draw is one item: a page break moves it whole, and what it holds is written unturned under one matrix, so text, pictures and borders need nothing of their own. The page count and link annotations look inside it, and its container grows to the box it stands in. Fields, subforms and draws in a flow don't turn yet. That issue's own form is static, so it already shows Acrobat's page: this is for dynamic forms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
1050 lines
50 KiB
Rust
1050 lines
50 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, n, parse_hex32, rgb, rgb_array, standard_font, state_name};
|
|
pub(crate) use strip::USAGE_RIGHTS_REMOVED;
|
|
use strip::drop_usage_rights;
|
|
pub use strip::{Stripped, XfaShape, existing_layout, strip, xfa_shape};
|
|
|
|
/// Private key on generated fields: the template's SOM path, so data can be put back.
|
|
pub const SOM_KEY: &[u8] = b"PCSom";
|
|
/// Private key on the AcroForm: what laying the form out produced, so a reopened file is not
|
|
/// 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 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";
|
|
|
|
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 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.
|
|
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 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.
|
|
const MAX_FIELDS_SEARCHED: usize = 20_000;
|
|
/// Deepest field nesting followed.
|
|
const MAX_FIELD_DEPTH: usize = 32;
|
|
|
|
/// 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 stack: Vec<(Object, String, bool, usize)> =
|
|
fields.as_array().into_iter().flatten().map(|f| (f.clone(), String::new(), false, 0)).collect();
|
|
let mut seen = 0usize;
|
|
while let Some((field, prefix, parent_is_sig, depth)) = stack.pop() {
|
|
seen += 1;
|
|
if seen > MAX_FIELDS_SEARCHED {
|
|
break;
|
|
}
|
|
let field = doc.resolve(&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)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
|
stack.extend(kids.into_iter().map(|k| (k, String::new(), 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,
|
|
Some(t) => format!("{prefix}.{t}"),
|
|
None => prefix,
|
|
};
|
|
if !name.is_empty() {
|
|
names.insert(name.clone());
|
|
}
|
|
let is_sig = match d.name(b"FT") {
|
|
Some(ft) => ft == b"Sig",
|
|
None => parent_is_sig,
|
|
};
|
|
let kids: Vec<Object> = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).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(|| kids.iter().find_map(|k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned())));
|
|
out.entry(name.clone()).or_insert(Signed { value: value.clone(), appearance });
|
|
}
|
|
if depth < MAX_FIELD_DEPTH {
|
|
stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1)));
|
|
}
|
|
}
|
|
(out, names)
|
|
}
|
|
|
|
impl Emitter<'_> {
|
|
fn da(face: &Face) -> Vec<u8> {
|
|
format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes()
|
|
}
|
|
|
|
fn populate_widget_kind(&mut self, w: &Widget, d: &mut Dict, mk: &mut Dict) -> (i64, bool, Option<String>) {
|
|
let r = w.rect;
|
|
let mut ff: i64 = 0;
|
|
if w.read_only {
|
|
ff |= 1;
|
|
}
|
|
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 {
|
|
let mut aa = Dict::new();
|
|
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 = match self.radio_groups.get(group) {
|
|
Some((p, _)) => *p,
|
|
None => {
|
|
let mut g = Dict::new();
|
|
g.set(b"FT".to_vec(), Object::name("Btn"));
|
|
g.set(b"Ff".to_vec(), Object::Int((1 << 15) | (1 << 14)));
|
|
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()));
|
|
// The group's SOM path: the button's without its last step.
|
|
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.clone(), (p, Vec::new()));
|
|
self.fields.push(p);
|
|
self.field_count += 1;
|
|
p
|
|
}
|
|
};
|
|
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);
|
|
}
|
|
}
|
|
(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, 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"));
|
|
d.set(b"F".to_vec(), Object::Int(4));
|
|
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)?;
|
|
if self.images.len() < MAX_REMEMBERED_IMAGES {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 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,
|
|
Step::Turn(t) => {
|
|
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 => {
|
|
turn = None;
|
|
last_link = None;
|
|
content.extend_from_slice(b"Q\n");
|
|
continue;
|
|
}
|
|
};
|
|
// 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), Some(Step::Item(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)? {
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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));
|
|
}
|
|
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 !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 => {}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
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 mut em = Emitter {
|
|
doc,
|
|
images,
|
|
fields: Vec::new(),
|
|
radio_groups: HashMap::new(),
|
|
radio_selected: HashMap::new(),
|
|
field_count: 0,
|
|
warnings: Vec::new(),
|
|
signed,
|
|
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, .. } = em;
|
|
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 })
|
|
}
|
|
|
|
/// 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::parse_hex32;
|
|
|
|
#[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));
|
|
}
|
|
}
|