mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 04:58:34 -06:00
1296 lines
61 KiB
Rust
1296 lines
61 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};
|
||
|
||
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};
|
||
|
||
/// 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";
|
||
|
||
/// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT
|
||
/// markers). `None` for anything else.
|
||
pub fn jpeg_size(data: &[u8]) -> Option<(u32, u32)> {
|
||
jpeg_frame(data).map(|(w, h, _)| (w, h))
|
||
}
|
||
|
||
fn jpeg_frame(data: &[u8]) -> Option<(u32, u32, u8)> {
|
||
jpeg_info(data).map(|(w, h, c, _)| (w, h, c))
|
||
}
|
||
|
||
/// The SHA-256 of a picture's bytes, to find its XObject again.
|
||
fn content_hash(data: &[u8]) -> [u8; 32] {
|
||
use sha2::Digest;
|
||
sha2::Sha256::digest(data).into()
|
||
}
|
||
|
||
fn hex32(h: &[u8; 32]) -> Vec<u8> {
|
||
h.iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect()
|
||
}
|
||
|
||
fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> {
|
||
let mut out = [0u8; 32];
|
||
if s.len() != 64 {
|
||
return None;
|
||
}
|
||
for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) {
|
||
*o = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
/// Width, height, components and whether an Adobe APP14 segment is present (such CMYK JPEGs
|
||
/// store inverted values).
|
||
fn jpeg_info(data: &[u8]) -> Option<(u32, u32, u8, bool)> {
|
||
if data.get(0..2)? != [0xFF, 0xD8] {
|
||
return None;
|
||
}
|
||
let mut i = 2usize;
|
||
let mut guard = 0;
|
||
let mut adobe = false;
|
||
while i + 4 <= data.len() && guard < 10_000 {
|
||
guard += 1;
|
||
if *data.get(i)? != 0xFF {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
let marker = *data.get(i + 1)?;
|
||
if marker == 0xFF {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (0xD0..=0xD9).contains(&marker) || marker == 0x01 {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize;
|
||
if marker == 0xEE && data.get(i + 4..i + 9) == Some(b"Adobe") {
|
||
adobe = true;
|
||
}
|
||
let sof = matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC);
|
||
if sof {
|
||
let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32;
|
||
let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32;
|
||
let comps = *data.get(i + 9)?;
|
||
return (w > 0 && h > 0).then_some((w, h, comps, adobe));
|
||
}
|
||
i = i.checked_add(2)?.checked_add(len.max(2))?;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Format a number for content streams.
|
||
fn n(v: f64) -> String {
|
||
let v = if v.is_finite() { v } else { 0.0 };
|
||
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
|
||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||
if s == "-0" { "0".into() } else { s.into() }
|
||
}
|
||
|
||
fn rgb(c: [f64; 3]) -> String {
|
||
format!("{} {} {}", n(c[0]), n(c[1]), n(c[2]))
|
||
}
|
||
|
||
fn rgb_array(c: [f64; 3]) -> Object {
|
||
Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect())
|
||
}
|
||
|
||
fn standard_font(base: &str) -> Object {
|
||
let mut f = Dict::new();
|
||
f.set(b"Type".to_vec(), Object::name("Font"));
|
||
f.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||
f.set(b"BaseFont".to_vec(), Object::name(base));
|
||
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||
Object::Dict(f)
|
||
}
|
||
|
||
/// Every face the content streams or `/DA` strings may name.
|
||
fn all_faces() -> Vec<(&'static str, &'static str)> {
|
||
let mut out = Vec::new();
|
||
for fam in [Family::Helvetica, Family::Courier, Family::Times] {
|
||
for (b, i) in [(false, false), (true, false), (false, true), (true, true)] {
|
||
out.push((fam.resource_name(b, i), fam.base_font(b, i)));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The content that draws rounded box `r` on a page `page_h` tall: its fill along its curves,
|
||
/// then its edge inside it. A side a page break cut has no edge.
|
||
pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String {
|
||
let mut c = String::new();
|
||
if let Some(color) = r.fill {
|
||
c.push_str(&format!("{} rg {}f\n", rgb(color), outline(r.rect, r.radii, r.inverted, (false, false), page_h)));
|
||
}
|
||
if let Some((w, color, dashed)) = r.edge {
|
||
// The edge's centre line, half its width inside the box, so the edge stays in: a corner
|
||
// curving around the box tightens by as much, and one curving into it widens.
|
||
let inset = Rect::new(r.rect.x + w / 2.0, r.rect.y + w / 2.0, (r.rect.w - w).max(0.0), (r.rect.h - w).max(0.0));
|
||
let most = inset.w.min(inset.h) / 2.0;
|
||
let radii: [f64; 4] = std::array::from_fn(|i| match (r.radii[i], r.inverted[i]) {
|
||
(v, _) if v <= 0.0 => 0.0,
|
||
(v, true) => (v + w / 2.0).min(most),
|
||
(v, false) => (v - w / 2.0).clamp(0.0, most),
|
||
});
|
||
let path = outline(inset, radii, r.inverted, (r.open_top, r.open_bottom), page_h);
|
||
c.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), if dashed { "[2 2] 0 d " } else { "" }));
|
||
}
|
||
c
|
||
}
|
||
|
||
/// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii`
|
||
/// (top-left, top-right, bottom-right, bottom-left), each curving around the box or, when
|
||
/// `inverted`, into it. `open` leaves out the top or bottom side (and doesn't close the path):
|
||
/// those are where a page break cut the box. Ends with a space, for the paint operator.
|
||
fn outline(rect: Rect, radii: [f64; 4], inverted: [bool; 4], open: (bool, bool), page_h: f64) -> String {
|
||
let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom());
|
||
let pt = |q: (f64, f64)| format!("{} {}", n(q.0), n(page_h - q.1));
|
||
// Corner `i`: its point, then where its curve starts and ends, going clockwise.
|
||
let corner = |i: usize| {
|
||
let r = radii.get(i).copied().unwrap_or(0.0);
|
||
match i {
|
||
0 => ((x0, y0), (x0, y0 + r), (x0 + r, y0)),
|
||
1 => ((x1, y0), (x1 - r, y0), (x1, y0 + r)),
|
||
2 => ((x1, y1), (x1, y1 - r), (x1 - r, y1)),
|
||
_ => ((x0, y1), (x0 + r, y1), (x0, y1 - r)),
|
||
}
|
||
};
|
||
// Corner `i`'s curve, from its start: a quarter circle as one Bézier curve.
|
||
let curve = |i: usize| {
|
||
if radii.get(i).copied().unwrap_or(0.0) <= 0.0 {
|
||
return String::new();
|
||
}
|
||
let (p, a, b) = corner(i);
|
||
const K: f64 = 0.5523;
|
||
let (c1, c2) = if inverted.get(i).copied().unwrap_or(false) {
|
||
// Centred on the corner itself.
|
||
((a.0 + (b.0 - p.0) * K, a.1 + (b.1 - p.1) * K), (b.0 + (a.0 - p.0) * K, b.1 + (a.1 - p.1) * K))
|
||
} else {
|
||
((a.0 + (p.0 - a.0) * K, a.1 + (p.1 - a.1) * K), (b.0 + (p.0 - b.0) * K, b.1 + (p.1 - b.1) * K))
|
||
};
|
||
format!("{} {} {} c ", pt(c1), pt(c2), pt(b))
|
||
};
|
||
// Along corners `from`, joining them with straight sides.
|
||
let along = |from: &[usize]| {
|
||
let mut s = String::new();
|
||
for &i in from {
|
||
s.push_str(&format!("{} l {}", pt(corner(i).1), curve(i)));
|
||
}
|
||
s
|
||
};
|
||
match open {
|
||
(false, false) => format!("{} m {}{}{}{}h ", pt(corner(0).1), curve(0), along(&[1]), along(&[2]), along(&[3])),
|
||
(true, false) => format!("{} m {}{} l ", pt((x1, y0)), along(&[2, 3]), pt((x0, y0))),
|
||
(false, true) => format!("{} m {}{} l ", pt((x0, y1)), along(&[0, 1]), pt((x1, y1))),
|
||
(true, true) => format!("{} m {} l {} m {} l ", pt((x0, y0)), pt((x0, y1)), pt((x1, y0)), pt((x1, y1))),
|
||
}
|
||
}
|
||
|
||
/// A PDF appearance-state name for a check button's on value.
|
||
fn state_name(on: &str) -> String {
|
||
let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect();
|
||
if s.is_empty() || s == "Off" { "Yes".into() } else { s }
|
||
}
|
||
|
||
fn empty_form(doc: &mut Document, w: f64, h: f64) -> Object {
|
||
let mut d = Dict::new();
|
||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|v| Object::Real(*v)).collect()));
|
||
Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new()))))
|
||
}
|
||
|
||
fn js_action(js: &str) -> Object {
|
||
let mut a = Dict::new();
|
||
a.set(b"S".to_vec(), Object::name("JavaScript"));
|
||
a.set(b"JS".to_vec(), Object::String(PdfString::literal(js.as_bytes().to_vec())));
|
||
Object::Dict(a)
|
||
}
|
||
|
||
/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]).
|
||
pub(crate) const USAGE_RIGHTS_REMOVED: &str =
|
||
"the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked";
|
||
|
||
pub struct Written {
|
||
pub pages: usize,
|
||
pub fields: usize,
|
||
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>,
|
||
}
|
||
|
||
/// 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 signed signature fields the document came with, by full name. 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. Fields of an earlier layout pass are not read.
|
||
fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap<String, Signed> {
|
||
let mut out = HashMap::new();
|
||
let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return out };
|
||
let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return out };
|
||
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(SOM_KEY) {
|
||
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,
|
||
};
|
||
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
|
||
}
|
||
|
||
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;
|
||
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;
|
||
}
|
||
}
|
||
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 WidgetKind::Radio { group, .. } = &w.kind
|
||
&& let Some((_, kids)) = self.radio_groups.get_mut(group)
|
||
{
|
||
kids.push(r);
|
||
}
|
||
Ok(Some(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
|
||
}
|
||
|
||
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 = signed_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,
|
||
};
|
||
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 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 })
|
||
}
|
||
|
||
/// What [`strip`] took out, for the user to be told (the packets held more than the fields).
|
||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||
pub struct Stripped {
|
||
/// The packets removed, by name (`template`, `datasets`, `config`, `signature`, …).
|
||
pub packets: Vec<String>,
|
||
/// Data values in the datasets no field held (what the form's data had that its fields
|
||
/// didn't show); `None` when the template could not be read to tell.
|
||
pub unbound_data: Option<usize>,
|
||
/// Buttons whose click ran an XFA script, and image fields: they do nothing from now on.
|
||
pub script_buttons: usize,
|
||
pub image_fields: usize,
|
||
/// What the layout left out (pictures, rows past the cap), now for good.
|
||
pub layout_warnings: Vec<String>,
|
||
/// Reader usage rights (`/Perms /UR3`) were removed: changing the form voids them anyway.
|
||
pub usage_rights: bool,
|
||
/// Private keys removed from field dictionaries (`/PCSom`, `/PCXfaClick`, …).
|
||
pub field_keys: usize,
|
||
}
|
||
|
||
impl Stripped {
|
||
/// What was dropped, as lines for the user (empty when nothing worth saying).
|
||
pub fn notes(&self) -> Vec<String> {
|
||
let mut out = Vec::new();
|
||
if !self.packets.is_empty() {
|
||
out.push(format!("The XFA was taken out of the form: its {} packet(s) are gone from the saved file", self.packets.join(", ")));
|
||
}
|
||
match self.unbound_data {
|
||
Some(n) if n > 0 => out.push(format!("{n} value(s) the form's XFA data held that no field showed are gone with it")),
|
||
None => out.push("The template could not be read, so data the fields didn't show may be gone with the XFA".into()),
|
||
_ => {}
|
||
}
|
||
if self.script_buttons + self.image_fields > 0 {
|
||
out.push(format!("{} button(s) that ran XFA scripts and {} image field(s) do nothing now", self.script_buttons, self.image_fields));
|
||
}
|
||
for w in &self.layout_warnings {
|
||
out.push(format!("Kept as laid out: {w}"));
|
||
}
|
||
if self.usage_rights {
|
||
out.push("Adobe Reader extended features were removed (changing the form voids them)".into());
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
/// What a document's XFA entry says, from its dictionaries alone (no packet decoded).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct XfaShape {
|
||
/// `/NeedsRendering true`: the form expects to be laid out from the template.
|
||
pub needs_rendering: bool,
|
||
/// The AcroForm has fields of its own.
|
||
pub has_fields: bool,
|
||
/// PdfCraft laid the form out (the layout mark is there).
|
||
pub laid_out: bool,
|
||
}
|
||
|
||
impl XfaShape {
|
||
/// Whether taking the XFA out leaves a form every viewer shows: the pages PdfCraft laid out
|
||
/// (with their fields), or a static form's own pages and fields. A dynamic form that was
|
||
/// never laid out has only placeholder pages: stripping it would leave nothing.
|
||
pub fn strippable(self) -> bool {
|
||
self.laid_out || (!self.needs_rendering && self.has_fields)
|
||
}
|
||
}
|
||
|
||
/// The document's XFA entry, if any, without decoding a packet.
|
||
pub fn xfa_shape(doc: &Document) -> Option<XfaShape> {
|
||
let root = doc.root()?;
|
||
let catalog = doc.get(root);
|
||
let catalog = catalog.as_dict()?;
|
||
let acro = doc.resolve(catalog.get(b"AcroForm")?);
|
||
let acro = acro.as_dict()?;
|
||
acro.get(b"XFA")?;
|
||
let bool_of = |v: Option<&Object>| matches!(v.map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true)));
|
||
let needs_rendering = bool_of(catalog.get(b"NeedsRendering")) || bool_of(acro.get(b"NeedsRendering"));
|
||
let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false);
|
||
Some(XfaShape { needs_rendering, has_fields, laid_out: acro.contains(LAYOUT_KEY) })
|
||
}
|
||
|
||
/// Most field dictionaries walked for their private keys.
|
||
const MAX_STRIP_FIELDS: usize = 1_000_000;
|
||
|
||
/// Take the XFA out of a document, leaving what every viewer shows: the pages as laid out
|
||
/// and, when the fields were kept, an ordinary AcroForm. The packets (template, data and
|
||
/// all), `/NeedsRendering`, the layout mark, the overrides and Reader usage rights go, and
|
||
/// the fields lose the private keys PdfCraft gave them. A flattened form (no fields left)
|
||
/// becomes a plain PDF; one with its fields becomes a static form that Adobe's viewers no
|
||
/// longer lay out again. What the packets held beyond the fields is counted in the report,
|
||
/// so the user can be told; the caller decides whether the form is one to strip
|
||
/// ([`XfaShape::strippable`]). A document without XFA is left as it is.
|
||
pub fn strip(doc: &mut Document) -> Result<Stripped, XfaError> {
|
||
let mut out = Stripped::default();
|
||
let Some(root) = doc.root() else { return Ok(out) };
|
||
let Some(mut catalog) = doc.get(root).as_dict().cloned() else { return Ok(out) };
|
||
let acro_obj = catalog.get(b"AcroForm").cloned();
|
||
let Some(acro_obj) = acro_obj else { return Ok(out) };
|
||
let Some(mut acro) = doc.resolve(&acro_obj).as_dict().cloned() else { return Ok(out) };
|
||
let Some(xfa) = acro.get(b"XFA").cloned() else { return Ok(out) };
|
||
// What is about to go, while it can still be read.
|
||
out.unbound_data = unbound_data(doc);
|
||
out.layout_warnings = existing_layout(doc).map(|r| r.warnings).unwrap_or_default();
|
||
let mut streams: Vec<ObjRef> = Vec::new();
|
||
match &*doc.resolve(&xfa) {
|
||
Object::Array(items) => {
|
||
let mut i = 0;
|
||
while i < items.len() {
|
||
let name = items.get(i).and_then(|n| n.as_string()).map(|s| s.to_text());
|
||
let stream = if name.is_some() { items.get(i + 1) } else { items.get(i) };
|
||
if let Some(n) = &name {
|
||
out.packets.push(n.clone());
|
||
}
|
||
if let Some(r) = stream.and_then(Object::as_ref) {
|
||
streams.push(r);
|
||
}
|
||
i += if name.is_some() { 2 } else { 1 };
|
||
}
|
||
}
|
||
Object::Stream(s) => {
|
||
if let Some(r) = xfa.as_ref() {
|
||
streams.push(r);
|
||
}
|
||
// One XDP: the packets it holds are the envelope's children.
|
||
let text = s.decoded_within(64 << 20).ok().and_then(|b| crate::packets::decode(&b)).map(|(t, _)| t).unwrap_or_default();
|
||
let opts = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 1_000_000 };
|
||
if crate::xml_nesting_depth(&text) <= crate::MAX_XML_DEPTH
|
||
&& let Ok(parsed) = roxmltree::Document::parse_with_options(&text, opts)
|
||
{
|
||
let root = parsed.root_element();
|
||
if root.tag_name().name() == "xdp" {
|
||
out.packets.extend(root.children().filter(|c| c.is_element()).map(|c| c.tag_name().name().to_string()).take(32));
|
||
} else {
|
||
out.packets.push(root.tag_name().name().to_string());
|
||
}
|
||
}
|
||
if out.packets.is_empty() {
|
||
out.packets.push("xdp".into());
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
acro.remove(b"XFA");
|
||
for key in [b"NeedsRendering".as_slice(), LAYOUT_KEY, crate::script::OVERRIDES_KEY] {
|
||
acro.remove(key);
|
||
}
|
||
// The private keys on every field and widget, down the tree (each dictionary once).
|
||
let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
|
||
let mut seen = std::collections::HashSet::new();
|
||
let mut todo: Vec<ObjRef> = Vec::new();
|
||
for r in fields.iter().filter_map(Object::as_ref) {
|
||
if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS {
|
||
todo.push(r);
|
||
}
|
||
}
|
||
while let Some(r) = todo.pop() {
|
||
let Some(d) = doc.get(r).as_dict().cloned() else { continue };
|
||
if d.contains(CLICK_KEY) {
|
||
out.script_buttons += 1;
|
||
}
|
||
if d.contains(IMAGE_FIELD_KEY) {
|
||
out.image_fields += 1;
|
||
}
|
||
let private: Vec<Vec<u8>> =
|
||
[SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect();
|
||
if !private.is_empty() {
|
||
out.field_keys += private.len();
|
||
doc.update_dict(r, |d| {
|
||
for k in &private {
|
||
d.remove(k);
|
||
}
|
||
})?;
|
||
}
|
||
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
|
||
for k in kids.iter().filter_map(Object::as_ref) {
|
||
if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS {
|
||
todo.push(k);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
match acro_obj.as_ref() {
|
||
Some(r) => doc.set(r, Object::Dict(acro)),
|
||
None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)),
|
||
}
|
||
catalog.remove(b"NeedsRendering");
|
||
out.usage_rights = drop_usage_rights(doc, &mut catalog);
|
||
doc.set(root, Object::Dict(catalog));
|
||
// The packet streams are referenced from nowhere else: freed, so an incremental save
|
||
// doesn't carry an unreachable copy of the data.
|
||
for r in streams {
|
||
doc.free(r);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Values in the datasets that no field of the laid-out form holds (leaf text nodes whose
|
||
/// data path no field binds to). `None` when the template can't be read.
|
||
fn unbound_data(doc: &Document) -> Option<usize> {
|
||
let data = crate::script::data_of(doc)?;
|
||
let tpl = crate::script::template_of(doc).ok()?;
|
||
let bound: std::collections::HashSet<crate::data::DataPath> =
|
||
crate::script::fields_by_som(doc).keys().map(|som| crate::data::data_path(&tpl, som)).collect();
|
||
fn walk(
|
||
n: &crate::data::DataNode,
|
||
path: &mut crate::data::DataPath,
|
||
bound: &std::collections::HashSet<crate::data::DataPath>,
|
||
count: &mut usize,
|
||
depth: usize,
|
||
) {
|
||
if depth > 64 {
|
||
return;
|
||
}
|
||
let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||
for c in &n.children {
|
||
let idx = seen.entry(c.name.as_str()).or_insert(0);
|
||
path.push((c.name.clone(), *idx));
|
||
*idx += 1;
|
||
if c.children.is_empty() {
|
||
if !c.text.trim().is_empty() && !bound.contains(path) {
|
||
*count += 1;
|
||
}
|
||
} else {
|
||
walk(c, path, bound, count, depth + 1);
|
||
}
|
||
path.pop();
|
||
}
|
||
}
|
||
let mut count = 0;
|
||
walk(&data, &mut Vec::new(), &bound, &mut count, 0);
|
||
Some(count)
|
||
}
|
||
|
||
/// Take the usage rights (`/Perms /UR3`, or the older `/UR`) off a catalog, keeping the rest of
|
||
/// `/Perms` (a certification's `/DocMDP`). They are signed over the form as it was: once its
|
||
/// pages or fields change they are void, and a viewer may lock every field of a form whose
|
||
/// usage rights are void, so they go, and the caller says so. Whether there were any.
|
||
fn drop_usage_rights(doc: &mut Document, catalog: &mut Dict) -> bool {
|
||
let Some(entry) = catalog.get(b"Perms").cloned() else { return false };
|
||
let Some(mut perms) = doc.resolve(&entry).as_dict().cloned() else { return false };
|
||
if !perms.contains(b"UR3") && !perms.contains(b"UR") {
|
||
return false;
|
||
}
|
||
perms.remove(b"UR3");
|
||
perms.remove(b"UR");
|
||
match entry.as_ref() {
|
||
_ if perms.is_empty() => {
|
||
catalog.remove(b"Perms");
|
||
}
|
||
Some(r) => doc.set(r, Object::Dict(perms)),
|
||
None => catalog.set(b"Perms".to_vec(), Object::Dict(perms)),
|
||
}
|
||
true
|
||
}
|
||
|
||
/// The layout a document already carries from an earlier pass, if any.
|
||
pub fn existing_layout(doc: &Document) -> Option<crate::Report> {
|
||
let root = doc.root()?;
|
||
let catalog = doc.get(root);
|
||
let acro = doc.resolve(catalog.as_dict()?.get(b"AcroForm")?);
|
||
let mark = doc.resolve(acro.as_dict()?.get(LAYOUT_KEY)?);
|
||
let mark = mark.as_dict()?;
|
||
let warnings = mark
|
||
.get(b"Warnings")
|
||
.map(|w| doc.resolve(w))
|
||
.and_then(|w| w.as_array().cloned())
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter_map(|w| w.as_string().map(|s| s.to_text()))
|
||
.take(100)
|
||
.collect();
|
||
Some(crate::Report {
|
||
pages: mark.int(b"Pages").unwrap_or(0).clamp(0, 100_000) as usize,
|
||
fields: mark.int(b"Fields").unwrap_or(0).clamp(0, 1_000_000) as usize,
|
||
template_bytes: 0,
|
||
warnings,
|
||
})
|
||
}
|
||
|
||
/// The rectangle of a widget in PDF user space (for tests).
|
||
pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] {
|
||
[r.x, page_height - r.bottom(), r.right(), page_height - r.y]
|
||
}
|