mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 05:58:12 -06:00
* M12: lay dynamic XFA forms out from their template (#60) A dynamic XFA form is a PDF shell around an XML template: one placeholder page ("requires Adobe Reader") and /NeedsRendering true. Every viewer without an XFA engine shows the placeholder, and so did PrintCraft. New crate printcraft-xfa (L3, already reserved in the layering table). It reads the XDP packets, parses the template (XFA 3.3: subforms, areas, fields, draws, exclusion groups, page sets, fonts, paragraphs, borders, captions, rich text, inline JPEGs, occurrences, breaks, overflow leaders, column widths), lays it out (positioned, tb, lr-tb, table/row; containers without a width size to their content; flow across content areas and page masters; header rows repeat after a break; hidden takes no space; page number and count fields resolve) and writes real pages and AcroForm widgets into the document: text, date (AFDate format and keystroke actions from the picture clause), check boxes, radio groups, push buttons with reset/print/save/URI actions. Standard-14 fonts stand in for Arial, Courier New and friends; nothing is embedded. The engine lays a dynamic form out when it opens it. The original bytes stay: the layout is one appended revision, the widgets get appearances from the forms crate, and the XFA packets remain so Adobe's viewers keep rendering the form their way. A marker on the AcroForm (/PCXfaLayout) stops a saved form from being laid out twice; each field keeps its SOM path (/PCSom) for the data round trip to come. doc_info, the CLI's info and the notice bar report the layout. Checked on IRCC's IMM 5669 (Schedule A, Designer 6.2, 464 KB template, AES-encrypted, usage rights): four pages and 308 fields in 27 ms, the same page structure as the form, filled through form_fill, saved in place with the original bytes intact and reopened with the values. Not done, and said so in the parity notes: the datasets packet is neither read nor written, so Adobe's viewers don't show values filled in here; no FormCalc or XFA JavaScript (rows don't grow, nothing calculates); choice lists are text fields; signature, image and barcode fields are blank; metrics are approximate and fidelity is eyeballed, not measured. Tests: crates/xfa (parsing, sizing, pagination with repeated headers, PDF writing, hostile input), the engine (open, fill, incremental save, reopen without a second layout), the automation tools (open, info, render, fill, save, reopen) and the UI notice (kittest). * M12: XFA forms read and write their datasets packet; static forms too (#60) With the layout alone, a form filled in PrintCraft opened blank in Adobe Reader, and one filled in Acrobat opened blank here: Adobe's viewers keep the values in the XFA datasets packet, not in the AcroForm fields. The xfa crate's new data module reads the datasets packet (normal binding: a field's node is named after it under its named ancestor subforms, repeated instances as repeated siblings) and writes it back from the fields. Laying a form out now takes each field's value from its data node (dates in ISO form shown in the field's pattern, check boxes and radio groups by their on values) and gives a repeating subform or table row as many instances as the data has, so rows another viewer added appear. Every laid-out field carries its SOM path and its on/off items, so the packet can be rebuilt from the AcroForm at any time. The engine writes the datasets after every form edit (one revision with the field change), and on open applies the datasets to the fields of a static XFA form, or of a dynamic one laid out earlier, when another viewer changed them since. The static-form notice now says the two are kept in step instead of warning that Acrobat may disagree. Checked on IRCC's IMM 5669: fill, save in place (original bytes intact), and the file holds <IMM_5669><page1><familyName>SINGH</familyName>…, the structure Acrobat writes for this form; reopening shows the values. Not done: explicit bind refs, global binding and data descriptions are not followed; no import or export of standalone XML or XDP data files. * M12: XFA layout memoizes container sizes, bounding measurement work (#60) A container without a width measured its content for its width and again for its height at every level, so nested width-less subforms took 2^depth measurements, all synchronously in Session::open. Container sizes are now cached per (node, width, depth), the root and page-area items keep stable addresses instead of being cloned per measurement, and a measurement budget turns anything left into TooLarge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M12: rustfmt the merged XFA notice (#60) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M12: XFA datasets are merged into, not replaced; capped; UTF-16 kept; rewrites recorded (#60) - write_datasets merged nothing: it replaced <xfa:data> with the AcroForm-bound values alone, dropping unbound, namespaced and deep data. It now edits the existing packet text in place: a bound node's text is replaced only when it differs, missing nodes are added below the deepest existing one, and everything else (unbound data, other namespaces, attributes, comments, layout) stays byte for byte. A packet of an XFA array that relies on the envelope's prefixes is read inside a wrapper declaring them. - What can't be written (a node holding structured content, too-deep paths or absurd SOM indices, conflicting fields, an unreadable or non-UTF packet) comes back as DatasetsWrite.warnings; the engine keeps them in Document.xfa_warnings and info.warnings (surviving re-reads), and doc_info reports xfa_warnings. - Building data counted same-name siblings for every one it added (quadratic in an untrusted index) with no total cap. Children are now indexed by name, indices above 10,000 and paths deeper than 64 are refused, and one write creates at most 20,000 nodes. - Packets are decoded exactly (UTF-8, UTF-16 LE/BE with or without BOM) and written back in the encoding they came in; anything else is reported and left untouched instead of being rewritten from a lossy UTF-8 reading. - A static XFA form whose fields are rewritten from the datasets on open now says so in info.warnings, naming the fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
298 lines
12 KiB
Rust
298 lines
12 KiB
Rust
//! Text measurement and line breaking with the standard 14 fonts that stand in for the
|
|
//! template's typefaces (no font programs are embedded in dynamic XFA forms, and none are
|
|
//! bundled here).
|
|
|
|
use crate::model::{Font, HAlign, Para, Rect, RichText, Run};
|
|
|
|
/// The standard-14 family a typeface maps to.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
pub enum Family {
|
|
Helvetica,
|
|
Courier,
|
|
Times,
|
|
}
|
|
|
|
impl Family {
|
|
pub fn of(typeface: &str) -> Family {
|
|
let t = typeface.to_ascii_lowercase();
|
|
if t.contains("courier") || t.contains("mono") {
|
|
Family::Courier
|
|
} else if t.contains("times")
|
|
|| t.contains("georgia")
|
|
|| t.contains("garamond")
|
|
|| t.contains("minion")
|
|
|| t.contains("serif") && !t.contains("sans")
|
|
{
|
|
Family::Times
|
|
} else {
|
|
Family::Helvetica
|
|
}
|
|
}
|
|
|
|
/// The PDF base font name for a face.
|
|
pub fn base_font(self, bold: bool, italic: bool) -> &'static str {
|
|
match (self, bold, italic) {
|
|
(Family::Helvetica, false, false) => "Helvetica",
|
|
(Family::Helvetica, true, false) => "Helvetica-Bold",
|
|
(Family::Helvetica, false, true) => "Helvetica-Oblique",
|
|
(Family::Helvetica, true, true) => "Helvetica-BoldOblique",
|
|
(Family::Courier, false, false) => "Courier",
|
|
(Family::Courier, true, false) => "Courier-Bold",
|
|
(Family::Courier, false, true) => "Courier-Oblique",
|
|
(Family::Courier, true, true) => "Courier-BoldOblique",
|
|
(Family::Times, false, false) => "Times-Roman",
|
|
(Family::Times, true, false) => "Times-Bold",
|
|
(Family::Times, false, true) => "Times-Italic",
|
|
(Family::Times, true, true) => "Times-BoldItalic",
|
|
}
|
|
}
|
|
|
|
/// The resource name used in content streams and `/DA` strings (Acrobat's conventions).
|
|
pub fn resource_name(self, bold: bool, italic: bool) -> &'static str {
|
|
match (self, bold, italic) {
|
|
(Family::Helvetica, false, false) => "Helv",
|
|
(Family::Helvetica, true, false) => "HeBo",
|
|
(Family::Helvetica, false, true) => "HeOb",
|
|
(Family::Helvetica, true, true) => "HeBO",
|
|
(Family::Courier, false, false) => "Cour",
|
|
(Family::Courier, true, false) => "CoBo",
|
|
(Family::Courier, false, true) => "CoOb",
|
|
(Family::Courier, true, true) => "CoBO",
|
|
(Family::Times, false, false) => "TiRo",
|
|
(Family::Times, true, false) => "TiBo",
|
|
(Family::Times, false, true) => "TiIt",
|
|
(Family::Times, true, true) => "TiBI",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A resolved face: family, weight, posture, size and colour.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Face {
|
|
pub family: Family,
|
|
pub bold: bool,
|
|
pub italic: bool,
|
|
pub size: f64,
|
|
pub color: [f64; 3],
|
|
pub underline: bool,
|
|
}
|
|
|
|
impl Face {
|
|
pub fn of(font: &Font) -> Face {
|
|
Face {
|
|
family: Family::of(&font.typeface),
|
|
bold: font.bold,
|
|
italic: font.italic,
|
|
size: font.size.clamp(1.0, 300.0),
|
|
color: font.color.0,
|
|
underline: font.underline,
|
|
}
|
|
}
|
|
|
|
pub fn with_run(&self, run: &Run) -> Face {
|
|
Face {
|
|
family: self.family,
|
|
bold: run.bold.unwrap_or(self.bold),
|
|
italic: run.italic.unwrap_or(self.italic),
|
|
size: run.size.unwrap_or(self.size).clamp(1.0, 300.0),
|
|
color: self.color,
|
|
underline: run.underline.unwrap_or(self.underline),
|
|
}
|
|
}
|
|
|
|
pub fn resource_name(&self) -> &'static str {
|
|
self.family.resource_name(self.bold, self.italic)
|
|
}
|
|
|
|
/// Advance of `s` at this face's size.
|
|
pub fn width(&self, s: &str) -> f64 {
|
|
match self.family {
|
|
Family::Courier => s.chars().count() as f64 * 0.6 * self.size,
|
|
Family::Helvetica => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 1.04 } else { 1.0 },
|
|
Family::Times => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 0.96 } else { 0.92 },
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Default line height: XFA's font leading for the standard faces.
|
|
pub fn line_height(size: f64) -> f64 {
|
|
size * 1.15
|
|
}
|
|
|
|
/// One positioned piece of text, ready to emit.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Span {
|
|
pub x: f64,
|
|
/// Baseline, top-left based (y grows downwards).
|
|
pub baseline: f64,
|
|
pub text: String,
|
|
pub face: Face,
|
|
}
|
|
|
|
/// A laid-out block of text.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct Block {
|
|
pub spans: Vec<Span>,
|
|
/// Height used, from the top of the box.
|
|
pub height: f64,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct Word {
|
|
text: String,
|
|
face: Face,
|
|
width: f64,
|
|
}
|
|
|
|
/// Break the runs of a paragraph into words (spaces stay attached to the word before them).
|
|
fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String) -> Vec<Word> {
|
|
let mut out = Vec::new();
|
|
for run in runs {
|
|
let face = base.with_run(run);
|
|
let text: String = match &run.embed {
|
|
Some(id) => embed(id),
|
|
None => run
|
|
.text
|
|
.chars()
|
|
.filter_map(|c| match c {
|
|
'\r' | '\n' | '\t' | '\u{2028}' | '\u{2029}' | '\u{2009}' | '\u{202F}' | '\u{3000}' => Some(' '),
|
|
'\u{00AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' => None,
|
|
'\u{2011}' | '\u{2010}' | '\u{2012}' | '\u{2013}' => Some('-'),
|
|
'\u{2018}' | '\u{2019}' => Some('\''),
|
|
_ => Some(c),
|
|
})
|
|
.collect(),
|
|
};
|
|
let mut cur = String::new();
|
|
for ch in text.chars() {
|
|
cur.push(ch);
|
|
if ch == ' ' {
|
|
let width = face.width(&cur);
|
|
out.push(Word { text: std::mem::take(&mut cur), face: face.clone(), width });
|
|
}
|
|
}
|
|
if !cur.is_empty() {
|
|
let width = face.width(&cur);
|
|
out.push(Word { text: cur, face, width });
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Lay `rich` out inside `rect` with `para` and `base` face. `embed` resolves embedded fields to
|
|
/// their text. Returns the spans (absolute coordinates) and the height the text takes.
|
|
pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn Fn(&str) -> String) -> Block {
|
|
struct Line {
|
|
words: Vec<Word>,
|
|
width: f64,
|
|
size: f64,
|
|
first: bool,
|
|
}
|
|
let avail = (rect.w - para.margin_left - para.margin_right).max(1.0);
|
|
let mut lines: Vec<(Line, Option<HAlign>)> = Vec::new();
|
|
let mut total = 0.0;
|
|
for (pi, p) in rich.paragraphs.iter().enumerate() {
|
|
let ws = words(&p.runs, base, embed);
|
|
let mut line = Line { words: Vec::new(), width: 0.0, size: base.size, first: true };
|
|
let mut first_in_para = true;
|
|
let indent = |first: bool| if first { para.text_indent.max(0.0) } else { 0.0 };
|
|
for w in ws {
|
|
let fits = line.width + w.width <= avail - indent(line.first) || line.words.is_empty() && w.width <= avail - indent(line.first);
|
|
if !fits && !line.words.is_empty() {
|
|
let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false });
|
|
lines.push((done, p.h_align));
|
|
}
|
|
if w.width > avail - indent(line.first) && line.words.is_empty() {
|
|
// A word longer than a line: break it by character.
|
|
let mut piece = String::new();
|
|
for ch in w.text.chars() {
|
|
piece.push(ch);
|
|
if w.face.width(&piece) > avail - indent(line.first) && piece.chars().count() > 1 {
|
|
piece.pop();
|
|
let width = w.face.width(&piece);
|
|
line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width });
|
|
line.width += width;
|
|
line.size = line.size.max(w.face.size);
|
|
let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false });
|
|
lines.push((done, p.h_align));
|
|
piece.push(ch);
|
|
}
|
|
}
|
|
if !piece.is_empty() {
|
|
let width = w.face.width(&piece);
|
|
line.size = line.size.max(w.face.size);
|
|
line.width += width;
|
|
line.words.push(Word { text: piece, face: w.face.clone(), width });
|
|
}
|
|
first_in_para = false;
|
|
continue;
|
|
}
|
|
line.size = line.size.max(w.face.size);
|
|
line.width += w.width;
|
|
line.words.push(w);
|
|
first_in_para = false;
|
|
}
|
|
if !line.words.is_empty() || first_in_para && pi + 1 < rich.paragraphs.len() {
|
|
lines.push((line, p.h_align));
|
|
}
|
|
total += para.space_above + para.space_below;
|
|
}
|
|
let heights: Vec<f64> = lines.iter().map(|(l, _)| para.line_height.unwrap_or_else(|| line_height(l.size))).collect();
|
|
total += heights.iter().sum::<f64>();
|
|
let start_y = match para.v_align {
|
|
crate::model::VAlign::Top => rect.y,
|
|
crate::model::VAlign::Middle => rect.y + ((rect.h - total) / 2.0).max(0.0),
|
|
crate::model::VAlign::Bottom => rect.y + (rect.h - total).max(0.0),
|
|
};
|
|
let mut spans = Vec::new();
|
|
let mut y = start_y + para.space_above;
|
|
for ((line, align), lh) in lines.into_iter().zip(heights) {
|
|
let indent = if line.first { para.text_indent.max(0.0) } else { 0.0 };
|
|
// Trailing spaces don't count for alignment.
|
|
let trailing = line.words.last().map_or(0.0, |w| if w.text.ends_with(' ') { w.face.width(" ") } else { 0.0 });
|
|
let used = (line.width - trailing).max(0.0);
|
|
let mut x = rect.x + para.margin_left + indent;
|
|
x += match align.unwrap_or(para.h_align) {
|
|
HAlign::Left | HAlign::Justify => 0.0,
|
|
HAlign::Center => ((avail - indent - used) / 2.0).max(0.0),
|
|
HAlign::Right => (avail - indent - used).max(0.0),
|
|
};
|
|
// Baseline: ascent sits about 0.8 of the size below the line top for these faces.
|
|
let baseline = y + lh - (lh - line.size * 0.8).max(0.0) / 2.0 - line.size * 0.08;
|
|
let mut run: Option<Span> = None;
|
|
for w in line.words {
|
|
match &mut run {
|
|
Some(s) if s.face == w.face => s.text.push_str(&w.text),
|
|
_ => {
|
|
if let Some(s) = run.take() {
|
|
x += s.face.width(&s.text);
|
|
spans.push(s);
|
|
}
|
|
run = Some(Span { x, baseline, text: w.text, face: w.face });
|
|
}
|
|
}
|
|
}
|
|
if let Some(s) = run.take() {
|
|
spans.push(s);
|
|
}
|
|
y += lh;
|
|
}
|
|
Block { spans, height: total }
|
|
}
|
|
|
|
/// Height `rich` needs at `width` (for boxes without an explicit height).
|
|
pub fn measure_height(rich: &RichText, width: f64, para: &Para, base: &Face, embed: &dyn Fn(&str) -> String) -> f64 {
|
|
let p = Para { v_align: crate::model::VAlign::Top, ..para.clone() };
|
|
layout(rich, Rect::new(0.0, 0.0, width.max(1.0), 0.0), &p, base, embed).height
|
|
}
|
|
|
|
/// Plain text as rich text (paragraphs split on line breaks).
|
|
pub fn plain(text: &str) -> RichText {
|
|
RichText {
|
|
paragraphs: text
|
|
.split(['\n', '\r', '\u{2028}', '\u{2029}'])
|
|
.map(|l| crate::model::Paragraph { runs: vec![Run { text: l.to_string(), ..Run::default() }], h_align: None })
|
|
.collect(),
|
|
}
|
|
}
|