M12: links in XFA rich text become clickable links

This commit is contained in:
Eric Kaden
2026-10-23 16:16:40 -04:00
parent 8d99a5ce1f
commit 0b8758eda3
7 changed files with 274 additions and 12 deletions
+27
View File
@@ -6500,6 +6500,33 @@ fn xfa_rich_text_max_length_is_reported_and_enforced_through_tools() {
assert_eq!(family["value"], "éàöüñ");
}
/// A link in a dynamic XFA form's rich text is a link on the laid-out page, as link_list reports
/// it, and stays one after a save and a fresh open.
#[test]
fn links_in_xfa_rich_text_are_listed_through_tools() {
let dir = workdir("xfa-links");
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<draw name="help" w="6in"><font typeface="Arial" size="10pt"/><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>Questions? Email <a href="mailto:forms@example.com">forms@example.com</a>.</p></body></exData></value></draw>
<field name="name" w="3in" h="0.3in"><ui><textEdit/></ui></field>
</subform></template></xdp:xdp>"##;
std::fs::write(dir.join("links.pdf"), pdfcraft_xfa::fixtures::shell(xdp)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "links.pdf" }))["doc"].as_u64().unwrap();
let urls = |a: &mut Automation, doc: u64| -> Vec<String> {
let listed = ok(a, "link_list", json!({ "doc": doc }));
let links = listed["links"].as_array().cloned().unwrap_or_default();
links.iter().filter_map(|l| l["url"].as_str().map(str::to_string)).collect()
};
assert_eq!(urls(&mut a, doc), ["mailto:forms@example.com"]);
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "name": "Ada" } }));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" }));
ok(&mut a, "doc_close", json!({ "doc": doc }));
let doc = ok(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(urls(&mut a, doc), ["mailto:forms@example.com"], "one link after a save and a fresh open, not two");
}
#[test]
fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() {
let dir = workdir("xfa");
+2
View File
@@ -633,6 +633,8 @@ pub struct Run {
pub h_scale: Option<f64>,
/// A floating field embedded in the text (`xfa:embed="#id"`), shown by its value.
pub embed: Option<String>,
/// Where the text links to (`<a href="…">`), as the template writes it.
pub link: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+12 -1
View File
@@ -201,7 +201,7 @@ fn parse_common(n: XmlNode) -> Common {
}
/// Rich text: XHTML `body > p > span…`. `br` starts a new paragraph; `xfa:embed` spans become
/// embedded fields.
/// embedded fields; the text of an `<a href>` keeps where it links to.
fn parse_rich(body: XmlNode) -> RichText {
#[derive(Clone, Default)]
struct Style {
@@ -210,6 +210,8 @@ fn parse_rich(body: XmlNode) -> RichText {
underline: Option<bool>,
size: Option<f64>,
h_scale: Option<f64>,
/// The `href` of the `<a>` the text is in.
link: Option<String>,
}
/// `base` with the CSS declarations in `css` applied: weight, style, underline, size and
/// `xfa-font-horizontal-scale`. Anything else is ignored.
@@ -254,6 +256,7 @@ fn parse_rich(body: XmlNode) -> RichText {
size: style.size,
h_scale: style.h_scale,
embed: None,
link: style.link.clone(),
},
);
}
@@ -285,6 +288,13 @@ fn parse_rich(body: XmlNode) -> RichText {
"b" => st.bold = Some(true),
"i" => st.italic = Some(true),
"u" => st.underline = Some(true),
// A link's text keeps where it points; an `<a>` without one (a named
// anchor) leaves the text as it was.
"a" => {
if let Some(href) = c.attribute("href").map(str::trim).filter(|h| !h.is_empty()) {
st.link = Some(href.to_string());
}
}
_ => {}
}
let embed = c.attributes().find(|a| a.name() == "embed").map(|a| a.value().trim_start_matches('#').to_string());
@@ -300,6 +310,7 @@ fn parse_rich(body: XmlNode) -> RichText {
size: st.size,
h_scale: st.h_scale,
embed: Some(e),
link: st.link.clone(),
},
);
}
+99 -3
View File
@@ -10,7 +10,7 @@ use pdfcraft_fonts::{literal, win_ansi};
use crate::XfaError;
use crate::layout::{Action, BorderShape, Form, Item, Page, Widget, WidgetKind};
use crate::model::Rect;
use crate::text::{Face, Family};
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";
@@ -574,8 +574,8 @@ impl Emitter<'_> {
}
/// 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 as
/// annotations. Returns the page object.
/// 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();
@@ -583,6 +583,8 @@ impl Emitter<'_> {
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() {
match item {
Item::Fill { rect, color } => {
@@ -629,6 +631,9 @@ impl Emitter<'_> {
);
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 };
@@ -668,6 +673,97 @@ impl Emitter<'_> {
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.starts_with("//") && rest.len() > 2 } else { scheme.eq_ignore_ascii_case("mailto") && !rest.is_empty() };
ok.then_some(href)
}
/// Replace the document's pages with the laid-out form and add its fields to the AcroForm. The
+121
View File
@@ -350,6 +350,127 @@ fn form_text_the_standard_fonts_cannot_draw_is_reported() {
assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts")));
}
/// A template whose one draw holds `body` as rich text, in a box `w` wide.
fn rich_draw_form(w: &str, body: &str) -> String {
format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<pageSet><pageArea name="page"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<draw name="note" w="{w}"><font typeface="Arial" size="10pt"/><value><exData contentType="text/html"><body xmlns="http://www.w3.org/1999/xhtml"><p>{body}</p></body></exData></value></draw>
</subform></template></xdp:xdp>"##
)
}
/// The link annotations on the pages of `doc`: each one's URI and rectangle.
fn text_links(doc: &Document) -> Vec<(String, [f64; 4])> {
let catalog = doc.get(doc.root().unwrap());
let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap());
let kids = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().clone();
let mut out = Vec::new();
for page in &kids {
let page = doc.resolve(page);
let Some(annots) = page.as_dict().unwrap().get(b"Annots").map(|a| doc.resolve(a).as_array().unwrap().clone()) else { continue };
for a in &annots {
let a = doc.resolve(a);
let d = a.as_dict().unwrap();
if d.name(b"Subtype") != Some(&b"Link"[..]) {
continue;
}
let action = doc.resolve(d.get(b"A").unwrap());
let uri =
action.as_dict().unwrap().get(b"URI").and_then(Object::as_string).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap();
let r: Vec<f64> = doc.resolve(d.get(b"Rect").unwrap()).as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
out.push((uri, [r[0], r[1], r[2], r[3]]));
}
}
out
}
/// An `<a href>` in rich text keeps where it links to on every run of its text, styled or not;
/// the text around it and a named anchor without an address link nowhere.
#[test]
fn rich_text_links_keep_their_address() {
let (tpl, _) = crate::parse(&rich_draw_form(
"4in",
r#"Write to <a href=" mailto:forms@example.com ">forms@<b>example</b>.com</a> or see <a name="top">the top</a>."#,
))
.unwrap();
let Node::Draw(d) = &tpl.root.children[0] else { panic!("the draw") };
let Value::Rich(rich) = &d.value else { panic!("rich text") };
let runs: Vec<(&str, Option<&str>)> = rich.paragraphs.iter().flat_map(|p| &p.runs).map(|r| (r.text.as_str(), r.link.as_deref())).collect();
assert_eq!(
runs,
[
("Write to ", None),
("forms@", Some("mailto:forms@example.com")),
("example", Some("mailto:forms@example.com")),
(".com", Some("mailto:forms@example.com")),
(" or see ", None),
("the top", None),
(".", None),
]
);
}
/// A link's text becomes a link annotation on the page, one over each line it takes when it
/// wraps, each over its own text; a second layout replaces them rather than adding more.
#[test]
fn links_in_rich_text_become_link_annotations_one_per_line() {
let body = r#"See <a href="https://example.com/forms/help">the help pages for this form, which explain every block</a> first."#;
let mut doc = Document::open(Arc::new(shell(&rich_draw_form("2in", body)))).unwrap();
let report = render_into(&mut doc).unwrap();
assert!(report.warnings.iter().all(|w| !w.contains("link")), "{:?}", report.warnings);
let links = text_links(&doc);
assert!(links.len() >= 2, "the link wraps onto at least two lines: {links:?}");
assert!(links.iter().all(|(uri, _)| uri == "https://example.com/forms/help"));
// One per line: each below the one before, all inside the 2-inch box (from x = 36pt).
for pair in links.windows(2) {
assert!(pair[1].1[3] <= pair[0].1[1] + 0.01, "a line below the one before: {pair:?}");
}
for (_, r) in &links {
assert!(r[0] >= 36.0 - 0.01 && r[2] <= 36.0 + 144.0 + 0.01 && r[2] > r[0] && r[3] > r[1], "{r:?}");
}
// The first line's link starts after "See ", not at the box's edge.
assert!(links[0].1[0] > 36.0 + 5.0, "{:?}", links[0].1);
// A second layout replaces the pages, and their links with them.
let tpl = crate::script::template_of(&doc).unwrap();
rerender(&mut doc, &tpl).unwrap();
assert_eq!(text_links(&doc), links, "laid out again: the same links, not twice as many");
// Saved and opened again, they are still there.
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert_eq!(text_links(&Document::open(Arc::new(bytes)).unwrap()), links);
// A link whose text changes style partway is one link on its line, over all its text.
let mut doc =
Document::open(Arc::new(shell(&rich_draw_form("4in", r#"Write to <a href="mailto:forms@example.com">forms@<b>example</b>.com</a> today."#))))
.unwrap();
render_into(&mut doc).unwrap();
let links = text_links(&doc);
assert_eq!(links.len(), 1, "{links:?}");
let arial = Font { typeface: "Arial".into(), size: 10.0, ..Default::default() };
let plain = crate::text::Face::of(&arial);
let bold = crate::text::Face::of(&Font { bold: true, ..arial.clone() });
let width = plain.width("forms@") + bold.width("example") + plain.width(".com");
let r = links[0].1;
assert!((r[2] - r[0] - width).abs() < 0.01, "over the whole address: {} against {width}", r[2] - r[0]);
}
/// Only web and email addresses become links. Scripts, files, data, relative paths and empty
/// or overlong addresses stay plain text (still drawn), with one warning for the form.
#[test]
fn links_to_anything_but_web_and_email_addresses_stay_plain_text() {
let long = format!("https://example.com/{}", "a".repeat(2100));
let body = format!(
r#"<a href="javascript:app.alert(1)">one</a> <a href="file:///etc/passwd">two</a> <a href="data:text/html,x">three</a> <a href="help.html">four</a> <a href="HTTPS:">five</a> <a href="{long}">six</a> <a href="MailTo:a@example.com">seven</a>"#
);
let mut doc = Document::open(Arc::new(shell(&rich_draw_form("7in", &body)))).unwrap();
let report = render_into(&mut doc).unwrap();
assert_eq!(text_links(&doc).into_iter().map(|(u, _)| u).collect::<Vec<_>>(), ["MailTo:a@example.com"]);
let warned: Vec<&String> = report.warnings.iter().filter(|w| w.contains("link")).collect();
assert_eq!(warned.len(), 1, "one warning for the form: {:?}", report.warnings);
assert!(warned[0].contains("http, https") && warned[0].contains("mailto"), "{warned:?}");
}
#[test]
fn documents_without_xfa_are_not_touched() {
let mut doc = Document::new_empty();
+11 -6
View File
@@ -153,6 +153,8 @@ pub struct Span {
pub baseline: f64,
pub text: String,
pub face: Face,
/// Where the text links to (its run's `<a href>`), as the template writes it.
pub link: Option<String>,
}
/// A laid-out block of text.
@@ -168,6 +170,8 @@ struct Word {
text: String,
face: Face,
width: f64,
/// The link of the run the word came from.
link: Option<String>,
}
/// The width of `w` without the spaces it ends with: what it takes at the end of a line, where
@@ -224,12 +228,12 @@ fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str, usize) -> String, embed
cur.push(ch);
if ch == ' ' {
let width = face.width(&cur);
out.push(Word { text: std::mem::take(&mut cur), face: face.clone(), width });
out.push(Word { text: std::mem::take(&mut cur), face: face.clone(), width, link: run.link.clone() });
}
}
if !cur.is_empty() {
let width = face.width(&cur);
out.push(Word { text: cur, face, width });
out.push(Word { text: cur, face, width, link: run.link.clone() });
}
}
out
@@ -277,7 +281,7 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn
if advance > avail - indent(line.first) && piece.len() > ch.len_utf8() {
piece.pop();
let width = w.face.width(&piece);
line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width });
line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width, link: w.link.clone() });
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 });
@@ -290,7 +294,7 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn
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 });
line.words.push(Word { text: piece, face: w.face.clone(), width, link: w.link.clone() });
}
first_in_para = false;
continue;
@@ -335,16 +339,17 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn
};
// 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;
// Words of one face and one link make one span, so a link's text is a span of its own.
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),
Some(s) if s.face == w.face && s.link == w.link => 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 });
run = Some(Span { x, baseline, text: w.text, face: w.face, link: w.link });
}
}
}
+2 -2
View File
@@ -5249,8 +5249,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn"]
notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools"]
notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
[[feature]]
id = "form.xfa-formcalc"