mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 04:58:34 -06:00
964 lines
48 KiB
Rust
964 lines
48 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, 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()
|
|
}
|
|
|
|
/// 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 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)));
|
|
if let Some(Action::Script(_)) = &w.action {
|
|
// 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));
|
|
} else if let Some(a) = &w.action {
|
|
let mut ad = Dict::new();
|
|
match a {
|
|
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(u) => {
|
|
ad.set(b"S".to_vec(), Object::name("URI"));
|
|
ad.set(b"URI".to_vec(), Object::String(PdfString::literal(u.as_bytes().to_vec())));
|
|
}
|
|
Action::Script(_) => {}
|
|
}
|
|
d.set(b"A".to_vec(), Object::Dict(ad));
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
for (i, item) in page.items.iter().enumerate() {
|
|
// 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!(page.items.get(i + 1), 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)? {
|
|
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());
|
|
}
|
|
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>) -> Result<Option<ObjRef>, XfaError> {
|
|
let piece = self.link_piece(s, page_height);
|
|
let Some((target, rect)) = piece else {
|
|
*last = None;
|
|
return Ok(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))
|
|
}
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
}
|