M12: XFA rows in areas get their instances, and areas stay out of data paths (#853)
A Designer form whose table (a top-to-bottom subform inside an area of a positioned page) asks for several rows with `<occur min="10"/>` was laid out with one row, and a FormCalc total looping over the rows stayed empty. - layout: a repeating subform inside a top-to-bottom container in positioned content is drawn and measured once per instance (occur and data), with its own SOM index, as flowed content already was. One `instances` helper now serves flowing, table rows and this case. - data: areas are named in SOM expressions but are not data scopes. `data_path(tpl, som)` leaves them out (unknown segments are kept), and layout, the script tree, reading and writing the datasets and the engine's value and row operations use it. Before, values Adobe's viewers saved were not read, what was filled here went under a new element named after the area (which Adobe's viewers would most likely not read), and scripts counted the rows there: one, so a loop over the table's rows failed on the missing ones. - write_datasets_with / read_values_with take the parsed template when the caller has it (the engine's cached one); the old functions parse the document's. - Tests: layout regression, data_path rules, and an engine round trip shaped like such a form (rows, Adobe-written values, a FormCalc total over every row, the data written back, reopening). Signed-off-by: Hadi Chokr <hadichokr@icloud.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
6116b0034c
commit
8309c84a59
|
|
@ -216,6 +216,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
|||
## Log
|
||||
Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-10-10 (M12, XFA rows and areas):** Dynamic XFA forms lay out a repeating subform inside a top-to-bottom container in positioned content (a table in an area) with all its instances, drawn and measured, instead of one; and areas no longer appear in data paths (`pdfcraft_xfa::data_path`), since they are not data scopes. Before, a form whose fields sit in an area read none of the values Adobe's viewers saved, wrote what was filled here under a new element named after the area, which Adobe's viewers would most likely not read, and a FormCalc total looping over the table's rows failed on the rows that were missing. Layout, the script tree, reading and writing the data and the engine's value and row operations share the one path rule. Synthetic tests cover the layout, the path rule and the whole round trip (rows, Adobe-written values, a FormCalc total over all rows, the written data, reopening). Still open for such forms: XFA script objects (`<variables><script>`), which aren't supported. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-10 (M9, #179 follow-up):** A Windows store certificate that can't sign is no longer left out of the Sign dialog silently: `windows::list` names it with the reason and Windows error code (no private key, a P-521/Brainpool/Ed25519 key, a key CNG can't open), the picker shows it greyed and unselectable, and `sign_windows_ids` returns it under `unusable`. A key that needs a permission prompt to open is listed instead of skipped. Reported by a user whose CA-issued identity showed in Acrobat but not here; a plain .p12 import into the CNG and legacy Microsoft providers was verified to list, so the next report will carry the reason. Store test adds a key-less certificate; overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-10 (M10.2, JPEG scanning follow-up to #729):** Skip marker fill bytes and keep reading JFIF, APP14 and ICC metadata after the frame header, stopping before scan data or at EOI. Invalid segment lengths return errors; damaged or mismatched ICC profiles retain #729's device-space fallback. Synthetic regression cases added but not run at the contributor's request. Estimates unchanged.
|
||||
- **2026-10-10 (M13, desktop action outputs):** Desktop Action Wizard keeps every result when input basenames repeat, or when the destination already has a result from an earlier run. It preserves the original filename and extension for a free name and adds numbered suffixes for occupied names; the bounded search returns an error rather than reusing an occupied path. A real-shell regression drives Start twice and reopens the three-page and one-page results, checking that the original inputs and earlier output remain intact. This covers the desktop writer separately from the CLI/MCP action_run repair in #877. Milestone and effort estimates unchanged.
|
||||
|
|
|
|||
|
|
@ -1975,9 +1975,14 @@ fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_xfa::FieldDatum>
|
|||
/// be written.
|
||||
/// `datasets`: the stream this edit already wrote, replaced in place rather than added again
|
||||
/// (and set to the one written).
|
||||
fn xfa_sync_datasets(doc: &mut pdfcraft_cos::Document, datasets: &mut Option<pdfcraft_cos::ObjRef>) -> Result<Vec<String>, String> {
|
||||
/// `tpl`: the form's parsed template when the document has one cached (else it is parsed).
|
||||
fn xfa_sync_datasets(
|
||||
doc: &mut pdfcraft_cos::Document,
|
||||
tpl: Option<&pdfcraft_xfa::model::Template>,
|
||||
datasets: &mut Option<pdfcraft_cos::ObjRef>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let data = xfa_field_data(doc);
|
||||
let r = pdfcraft_xfa::write_datasets_reusing(doc, &data, *datasets).map_err(|e| e.to_string())?;
|
||||
let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?;
|
||||
if r.stream.is_some() {
|
||||
*datasets = r.stream;
|
||||
}
|
||||
|
|
@ -2367,7 +2372,7 @@ impl Session {
|
|||
if let Some(tpl) = cx.xfa.clone() {
|
||||
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) {
|
||||
let datasets = &mut cx.xfa_datasets;
|
||||
let notes = guard(|| xfa_sync_datasets(&mut next, datasets))
|
||||
let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets))
|
||||
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
|
||||
.map_err(EditError::Write)?;
|
||||
cx.xfa_out.errors.extend(notes);
|
||||
|
|
@ -2399,8 +2404,8 @@ impl Session {
|
|||
// XFA forms keep their values in the datasets packet too, for Adobe's viewers.
|
||||
let mut xfa_notes = Vec::new();
|
||||
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) {
|
||||
let datasets = &mut cx.xfa_datasets;
|
||||
xfa_notes = guard(|| xfa_sync_datasets(&mut next, datasets))
|
||||
let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets);
|
||||
xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets))
|
||||
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
|
||||
.map_err(EditError::Write)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2325,6 +2325,46 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
|||
assert!(!d2.dirty);
|
||||
}
|
||||
|
||||
/// A repeating table inside an area of a positioned page (as in real Designer forms): every
|
||||
/// row is laid out, the values Adobe wrote (under the area's parent: areas are
|
||||
/// not data scopes) are read, a FormCalc total over all rows computes, and what is filled here
|
||||
/// is written back where Adobe's viewers read it, with no element for the area.
|
||||
#[test]
|
||||
fn xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them() {
|
||||
let data = "<form><page><table><row><reason>Regular</reason><days>4</days></row><row><reason/><days/></row><row><reason/><days/></row></table><carried>5</carried></page></form>";
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::leave_template(data)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("leave.pdf", None, bytes, None).expect("opens");
|
||||
let value = |s: &Session, n: &str| {
|
||||
s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.first().cloned().unwrap_or_default()
|
||||
};
|
||||
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
|
||||
// Three rows (occur min="3"), not one.
|
||||
assert_eq!(names(&s).iter().filter(|n| n.starts_with("reason")).count(), 3, "{:?}", names(&s));
|
||||
// Adobe's data, bound past the area.
|
||||
assert_eq!((value(&s, "reason"), value(&s, "days"), value(&s, "carried")), ("Regular".into(), "4".into(), "5".into()));
|
||||
// The total walks rows 0 to 2: 5 + 30 - 4.
|
||||
assert_eq!(value(&s, "rest"), "31", "{:?}", s.take_js_output(id));
|
||||
// A second regular row recalculates it.
|
||||
s.apply(id, Edit::SetFieldValue { name: "reason_2".into(), value: FieldValue::Text("Regular".into()) }).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "days_2".into(), value: FieldValue::Text("2".into()) }).unwrap();
|
||||
assert_eq!(value(&s, "rest"), "29", "{:?}", s.take_js_output(id));
|
||||
assert!(s.take_js_output(id).errors.is_empty());
|
||||
// Written where Adobe reads it: no element for the area, the rows under the table.
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let d = pdfcraft_xfa::data_of(&cos).unwrap();
|
||||
let path = |som: &str| pdfcraft_xfa::som_to_path(som);
|
||||
assert_eq!(d.count(&path("form[0].page[0]"), "box"), 0, "an element for the area was written");
|
||||
assert_eq!(d.count(&path("form[0].page[0].table[0]"), "row"), 3);
|
||||
assert_eq!(d.text_at(&path("form[0].page[0].table[0].row[1].days[0]")), Some("2"));
|
||||
assert_eq!(d.text_at(&path("form[0].page[0].rest[0]")), Some("29"));
|
||||
// Reopened, the values come back from that data.
|
||||
let mut s2 = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id2 = s2.open("again.pdf", None, s.get(id).unwrap().bytes.clone(), None).expect("reopens");
|
||||
let v2 = |n: &str| s2.get(id2).unwrap().form.iter().find(|f| f.name == n).unwrap().value.first().cloned().unwrap_or_default();
|
||||
assert_eq!((v2("days_2"), v2("rest")), ("2".into(), "29".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_run_formcalc_scripts() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template()));
|
||||
|
|
|
|||
|
|
@ -167,10 +167,10 @@ fn count_instances(root: &FormNode, parent_som: &str, name: &str) -> usize {
|
|||
parent.map_or(0, |p| p.children.iter().filter(|c| c.name == name).count())
|
||||
}
|
||||
|
||||
/// An instance SOM (`…table[0].row[2]`) split into its parent's data path and SOM, its name
|
||||
/// and its index.
|
||||
fn split_instance(som: &str) -> Option<(pdfcraft_xfa::DataPath, String, String, usize)> {
|
||||
let mut path = pdfcraft_xfa::som_to_path(som);
|
||||
/// An instance SOM (`…table[0].row[2]`) split into its parent's data path (areas left out,
|
||||
/// see [`pdfcraft_xfa::data_path`]) and SOM, its name and its index.
|
||||
fn split_instance(tpl: &Template, som: &str) -> Option<(pdfcraft_xfa::DataPath, String, String, usize)> {
|
||||
let mut path = pdfcraft_xfa::data_path(tpl, som);
|
||||
let (name, index) = path.pop()?;
|
||||
let parent_som = som.rsplit_once('.').map(|(p, _)| p).unwrap_or("").to_string();
|
||||
Some((path, parent_som, name, index))
|
||||
|
|
@ -356,7 +356,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
/// Give the object at `som` the value `value` (field `name` when it has a widget). Returns
|
||||
/// whether it changed.
|
||||
fn set_value(&mut self, som: &str, value: &str) -> bool {
|
||||
let path = pdfcraft_xfa::som_to_path(som);
|
||||
let path = pdfcraft_xfa::data_path(self.tpl, som);
|
||||
let name = self.by_som().get(som).cloned();
|
||||
let Some(name) = name else {
|
||||
// No widget (a hidden field, a draw, a row not laid out yet): the data alone.
|
||||
|
|
@ -484,7 +484,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
if matches!(info.kind, pdfcraft_forms::FieldKind::PushButton | pdfcraft_forms::FieldKind::Signature) {
|
||||
continue;
|
||||
}
|
||||
let path = pdfcraft_xfa::som_to_path(som);
|
||||
let path = pdfcraft_xfa::data_path(self.tpl, som);
|
||||
let text = data_text(info);
|
||||
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
|
||||
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
|
||||
|
|
@ -501,10 +501,12 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
// Instances of each repeating subform before the script ran, as the effects leave them.
|
||||
let mut counts: HashMap<(String, String), usize> = HashMap::new();
|
||||
{
|
||||
// A copy of the shared reference: `live()` borrows `self` mutably.
|
||||
let tpl = self.tpl;
|
||||
let live = self.live();
|
||||
for e in effects {
|
||||
if let XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } = e
|
||||
&& let Some((_, parent_som, name, _)) = split_instance(som)
|
||||
&& let Some((_, parent_som, name, _)) = split_instance(tpl, som)
|
||||
{
|
||||
let have = count_instances(&live.root, &parent_som, &name);
|
||||
counts.entry((parent_som, name)).or_insert(have);
|
||||
|
|
@ -542,7 +544,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
|||
}
|
||||
}
|
||||
XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => {
|
||||
let Some((parent, parent_som, name, index)) = split_instance(som) else { continue };
|
||||
let Some((parent, parent_som, name, index)) = split_instance(self.tpl, som) else { continue };
|
||||
let key = (parent_som, name.clone());
|
||||
let have = counts.get(&key).copied().unwrap_or(0);
|
||||
if matches!(e, XfaEffect::AddInstance { .. }) {
|
||||
|
|
@ -750,7 +752,7 @@ mod tests {
|
|||
assert!(!som_matches("form[0].page1[0].qty[0]", "qt"));
|
||||
assert!(!som_matches("form[0].page1[0].price[0]", "qty"));
|
||||
assert_eq!(
|
||||
split_instance("form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)),
|
||||
split_instance(&Template::default(), "form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)),
|
||||
Some((2, "form[0].table[0]".into(), "row".into(), 2))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ the automation tools) works on it unchanged. The engine does this when it opens
|
|||
container without a width is as wide as its content; flow across content areas and pages with
|
||||
page masters chosen by occurrence; a table's header row repeats after a break; presence
|
||||
`hidden` takes no space, `invisible` takes space; repeating subforms get their initial
|
||||
instances; fields that compute the page number or count on layout show the numbers.
|
||||
instances, in flowed content and in a top-to-bottom container inside positioned content (a
|
||||
table in an area) alike; fields that compute the page number or count on layout show the numbers.
|
||||
- **PDF** (`pdf::write_form`): pages with content streams in the standard 14 fonts (Arial and
|
||||
friends → Helvetica, Courier New → Courier, serif faces → Times; nothing is embedded), JPEG
|
||||
XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate`
|
||||
|
|
@ -34,7 +35,8 @@ the automation tools) works on it unchanged. The engine does this when it opens
|
|||
twice. The XFA packets stay, so Adobe's viewers keep rendering the form from them.
|
||||
|
||||
- **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data
|
||||
node at its SOM path (dates in ISO form, check and radio states by their on values), and a
|
||||
node at its data path: its SOM path without areas, which are named in SOM expressions but are
|
||||
not data scopes (`data_path`, as Adobe's viewers bind) (dates in ISO form, check and radio states by their on values), and a
|
||||
repeating subform or row gets as many instances as the data has. `write_datasets` merges the
|
||||
AcroForm fields' values (by `/PCSom`, or by the Designer field names of a static form) into
|
||||
the existing `xfa:data`: only the bound nodes' text changes and missing nodes are added, so
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@
|
|||
//!
|
||||
//! Binding is the default "normal" one: a field's data node is named after the field and nested
|
||||
//! under nodes named after its named ancestor subforms, repeated instances as repeated sibling
|
||||
//! elements. Explicit `bind ref` expressions are not followed.
|
||||
//! elements. Areas are named in SOM expressions but are not data scopes, so their contents bind
|
||||
//! under the area's parent ([`data_path`]). Explicit `bind ref` expressions are not followed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
use crate::XfaError;
|
||||
use crate::model::{Node, Template};
|
||||
use crate::packets::{Encoding, encode};
|
||||
|
||||
const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/";
|
||||
|
|
@ -96,6 +98,62 @@ pub fn som_to_path(som: &str) -> DataPath {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// The data path the object at `som` binds to: [`som_to_path`] without the areas on the way.
|
||||
/// An area is a named container in the form, so it is part of SOM expressions, but only
|
||||
/// subforms, exclusion groups and fields are data scopes: what an area holds binds under the
|
||||
/// area's parent, as Adobe's viewers write it. The template tells which segments are areas;
|
||||
/// segments it doesn't have (page-set objects, or a SOM from another template) are kept.
|
||||
pub fn data_path(tpl: &Template, som: &str) -> DataPath {
|
||||
let mut out = DataPath::new();
|
||||
// The template children the next segment is looked up in; `None` until the root matched,
|
||||
// and again once a segment is not a container the template has.
|
||||
let mut level: Option<&[Node]> = None;
|
||||
for (i, (name, index)) in som_to_path(som).into_iter().enumerate() {
|
||||
let found = match level {
|
||||
None if i == 0 && tpl.root.common.name.as_deref() == Some(name.as_str()) => Some(Container::Children(&tpl.root.children)),
|
||||
None => None,
|
||||
Some(children) => match named(children, &name, 0) {
|
||||
Some(Node::Subform(sf)) => Some(Container::Children(&sf.children)),
|
||||
Some(Node::Area(a)) => Some(Container::Area(&a.children)),
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
match found {
|
||||
Some(Container::Area(children)) => level = Some(children),
|
||||
Some(Container::Children(children)) => {
|
||||
level = Some(children);
|
||||
out.push((name, index));
|
||||
}
|
||||
None => {
|
||||
level = None;
|
||||
out.push((name, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
enum Container<'a> {
|
||||
Children(&'a [Node]),
|
||||
/// Contents bind under the area's parent.
|
||||
Area(&'a [Node]),
|
||||
}
|
||||
|
||||
/// The child of `nodes` named `name`, looking through unnamed subforms and areas (which have no
|
||||
/// SOM segment of their own).
|
||||
fn named<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> {
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
nodes.iter().find(|n| n.common().name.as_deref() == Some(name)).or_else(|| {
|
||||
nodes.iter().find_map(|n| match n {
|
||||
Node::Subform(sf) if sf.common.name.is_none() => named(&sf.children, name, depth + 1),
|
||||
Node::Area(a) if a.common.name.is_none() => named(&a.children, name, depth + 1),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// What a field holds, for writing the datasets.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FieldData {
|
||||
|
|
@ -342,11 +400,30 @@ struct Want {
|
|||
ensure: bool,
|
||||
}
|
||||
|
||||
fn wanted(doc: &Document, fields: &[FieldDatum]) -> Vec<Want> {
|
||||
/// `tpl`, or the document's own template when none is given (parsed once).
|
||||
fn with_template<T>(doc: &Document, tpl: Option<&Template>, f: impl FnOnce(Option<&Template>) -> T) -> T {
|
||||
match tpl {
|
||||
Some(t) => f(Some(t)),
|
||||
None => {
|
||||
let parsed = crate::script::template_of(doc).ok();
|
||||
f(parsed.as_ref())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`data_path`] when the template is known, else [`som_to_path`].
|
||||
fn path_of(tpl: Option<&Template>, som: &str) -> DataPath {
|
||||
match tpl {
|
||||
Some(t) => data_path(t, som),
|
||||
None => som_to_path(som),
|
||||
}
|
||||
}
|
||||
|
||||
fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec<Want> {
|
||||
let mut out = Vec::new();
|
||||
for f in fields {
|
||||
let som = som_of(doc, f);
|
||||
let path = som_to_path(&som);
|
||||
let path = path_of(tpl, &som);
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -415,7 +492,8 @@ fn build_children(wants: &[Want], warnings: &mut Vec<String>) -> String {
|
|||
|
||||
/// The `xfa:data` element for `fields`, built afresh.
|
||||
pub fn build_data(doc: &Document, fields: &[FieldDatum]) -> String {
|
||||
format!("<xfa:data>{}</xfa:data>", build_children(&wanted(doc, fields), &mut Vec::new()))
|
||||
let wants = with_template(doc, None, |t| wanted(doc, fields, t));
|
||||
format!("<xfa:data>{}</xfa:data>", build_children(&wants, &mut Vec::new()))
|
||||
}
|
||||
|
||||
fn fresh_datasets(data_children: &str) -> String {
|
||||
|
|
@ -749,7 +827,18 @@ pub fn write_datasets(doc: &mut Document, fields: &[FieldDatum]) -> Result<Datas
|
|||
/// [`write_datasets`], replacing the stream `reuse` in place when the packet is that stream
|
||||
/// (written earlier in the same edit) rather than adding another.
|
||||
pub fn write_datasets_reusing(doc: &mut Document, fields: &[FieldDatum], reuse: Option<ObjRef>) -> Result<DatasetsWrite, XfaError> {
|
||||
let wants = wanted(doc, fields);
|
||||
write_datasets_with(doc, None, fields, reuse)
|
||||
}
|
||||
|
||||
/// [`write_datasets_reusing`] with the form's parsed template when the caller has it (it says
|
||||
/// which SOM segments are areas, see [`data_path`]); `None` parses the document's.
|
||||
pub fn write_datasets_with(
|
||||
doc: &mut Document,
|
||||
tpl: Option<&Template>,
|
||||
fields: &[FieldDatum],
|
||||
reuse: Option<ObjRef>,
|
||||
) -> Result<DatasetsWrite, XfaError> {
|
||||
let wants = with_template(doc, tpl, |t| wanted(doc, fields, t));
|
||||
rewrite(doc, &[Op::Values(&wants)], reuse)
|
||||
}
|
||||
|
||||
|
|
@ -980,11 +1069,21 @@ fn rewrite(doc: &mut Document, ops: &[Op], reuse: Option<ObjRef>) -> Result<Data
|
|||
/// The values the datasets hold for `fields`: `(field name, what it should hold)`, only for
|
||||
/// fields the data mentions.
|
||||
pub fn read_values(doc: &Document, fields: &[FieldDatum]) -> Vec<(String, FieldData)> {
|
||||
read_values_with(doc, None, fields)
|
||||
}
|
||||
|
||||
/// [`read_values`] with the form's parsed template when the caller has it; `None` parses the
|
||||
/// document's.
|
||||
pub fn read_values_with(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> {
|
||||
with_template(doc, tpl, |t| values_at(doc, t, fields))
|
||||
}
|
||||
|
||||
fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> {
|
||||
let Ok(Some(p)) = crate::read_packets(doc) else { return Vec::new() };
|
||||
let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for f in fields {
|
||||
let path = som_to_path(&som_of(doc, f));
|
||||
let path = path_of(tpl, &som_of(doc, f));
|
||||
let Some(text) = data.text_at(&path) else { continue };
|
||||
let value = match &f.data {
|
||||
FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) {
|
||||
|
|
|
|||
|
|
@ -208,6 +208,55 @@ endif</script></event></field>
|
|||
.to_string()
|
||||
}
|
||||
|
||||
/// Shaped like a real Designer leave-request form: a positioned page whose unnamed subform holds
|
||||
/// an area (`box`) with a top-to-bottom table of positioned rows (`<occur min="3"/>`) and a
|
||||
/// read-only total that a FormCalc calculate works out over a fixed range of rows, as such forms
|
||||
/// do. `data` is `xfa:data` content as Adobe's viewers write it: areas are not data scopes, so
|
||||
/// there is no `box` element.
|
||||
pub fn leave_template(data: &str) -> String {
|
||||
let datasets = if data.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data>{data}</xfa:data></xfa:datasets>")
|
||||
};
|
||||
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="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform name="page" w="7.5in" h="9in">
|
||||
<subform w="7.5in" h="8in">
|
||||
<area name="box" x="0.25in" y="0.5in">
|
||||
<subform name="table" layout="tb" w="5in">
|
||||
<subform name="row" w="5in" h="0.3in"><occur min="3" max="-1"/>
|
||||
<field name="reason" x="0in" w="3in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
<field name="days" x="3in" w="1in" h="0.3in"><ui><numericEdit/></ui></field>
|
||||
</subform>
|
||||
</subform>
|
||||
<field name="carried" y="1.5in" w="2in" h="0.3in"><ui><numericEdit/></ui></field>
|
||||
<field name="allowance" y="2in" w="2in" h="0.3in"><ui><numericEdit/></ui><value><float>30</float></value></field>
|
||||
<field name="rest" y="2.5in" w="2in" h="0.3in" access="readOnly"><ui><numericEdit/></ui>
|
||||
<calculate><script>
|
||||
var left = carried + allowance
|
||||
for i=0 upto 2 step 1 do
|
||||
if (table.row[i].reason == "Regular") then
|
||||
left = left - table.row[i].days
|
||||
endif
|
||||
endfor
|
||||
this = left
|
||||
</script></calculate></field>
|
||||
</area>
|
||||
</subform>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
{datasets}
|
||||
</xdp:xdp>
|
||||
"##
|
||||
)
|
||||
}
|
||||
|
||||
/// A 2 × 2 RGBA PNG: red, green / blue, transparent.
|
||||
pub fn tiny_png() -> Vec<u8> {
|
||||
TINY_PNG.to_vec()
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use std::sync::Arc;
|
|||
use base64::Engine;
|
||||
|
||||
use crate::XfaError;
|
||||
use crate::data::{DataNode, is_on, iso_to_pattern, som_to_path};
|
||||
use crate::data::{DataNode, data_path, is_on, iso_to_pattern};
|
||||
use crate::model::*;
|
||||
use crate::text::{self, Block, Face, Span};
|
||||
|
||||
|
|
@ -508,11 +508,7 @@ impl Layouter<'_> {
|
|||
self.new_page()?;
|
||||
}
|
||||
// Data adds instances of a repeating subform (rows added in another viewer).
|
||||
let from_data = match (&sf.common.name, self.data) {
|
||||
(Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&som_to_path(&ctx.som), name),
|
||||
_ => 0,
|
||||
};
|
||||
let instances = instance_count(&sf.occur, from_data);
|
||||
let instances = self.instances(node, &ctx.som);
|
||||
let ctx_parent = ctx;
|
||||
for i in 0..instances {
|
||||
let ctx = ctx_parent.with_som(&sf.common, sib + i);
|
||||
|
|
@ -583,11 +579,7 @@ impl Layouter<'_> {
|
|||
&& row.layout == Layout::Row
|
||||
{
|
||||
// Rows added as data (in another viewer) repeat the row.
|
||||
let from_data = match (&row.common.name, self.data) {
|
||||
(Some(name), Some(d)) if row.occur.max != Some(1) => d.count(&som_to_path(&ctx.som), name),
|
||||
_ => 0,
|
||||
};
|
||||
let instances = instance_count(&row.occur, from_data);
|
||||
let instances = self.instances(child, &ctx.som);
|
||||
for inst in 0..instances {
|
||||
let h = self.row_height(row, &cols, inner_w, ctx, depth + 1);
|
||||
if self.make_room(h)?
|
||||
|
|
@ -626,6 +618,17 @@ impl Layouter<'_> {
|
|||
/// The size a node takes when given `avail_w` of width. Containers are measured once per
|
||||
/// (node, width, depth): measuring a width-less container measures its content twice (for
|
||||
/// its width, then its height), which would otherwise be exponential in the nesting.
|
||||
/// How many instances of `node` to lay out under the container at `parent_som`: a
|
||||
/// repeating subform's `occur` and data instances ([`instance_count`]); one for anything else.
|
||||
fn instances(&self, node: &Node, parent_som: &str) -> usize {
|
||||
let Node::Subform(sf) = node else { return 1 };
|
||||
let from_data = match (&sf.common.name, self.data) {
|
||||
(Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name),
|
||||
_ => 0,
|
||||
};
|
||||
instance_count(&sf.occur, from_data)
|
||||
}
|
||||
|
||||
fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) {
|
||||
let c = node.common();
|
||||
let spent = self.measures.get().saturating_add(1);
|
||||
|
|
@ -659,6 +662,9 @@ impl Layouter<'_> {
|
|||
|
||||
fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) {
|
||||
let c = node.common();
|
||||
// The container's own SOM, under which its repeating children count their instances.
|
||||
// Measurements are cached per template node, so instance 0 stands for all of them.
|
||||
let som = ctx.with_som(c, 0).som;
|
||||
let ctx = ctx.child(c);
|
||||
// A container without a width is as wide as its content (a radio group beside its
|
||||
// question, say); a leaf without one takes what is available.
|
||||
|
|
@ -684,9 +690,13 @@ impl Layouter<'_> {
|
|||
Node::Area(a) => self.positioned_height(&a.children, inner_w, &ctx, depth),
|
||||
Node::Subform(s) => match s.layout {
|
||||
Layout::Positioned => self.positioned_height(&s.children, inner_w, &ctx, depth),
|
||||
Layout::Tb => {
|
||||
s.children.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1).sum()
|
||||
}
|
||||
// Each instance of a repeating child takes its height again.
|
||||
Layout::Tb => s
|
||||
.children
|
||||
.iter()
|
||||
.filter(|k| k.common().presence.occupies())
|
||||
.map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1 * self.instances(k, &som) as f64)
|
||||
.sum(),
|
||||
Layout::LrTb | Layout::RlTb => self.packed_height(&s.children, inner_w, &ctx, depth),
|
||||
Layout::Table => {
|
||||
let cols = self.columns(s, inner_w);
|
||||
|
|
@ -943,8 +953,12 @@ impl Layouter<'_> {
|
|||
continue;
|
||||
}
|
||||
let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1);
|
||||
self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sib_index(&s.children, i))?;
|
||||
y += h;
|
||||
// A repeating subform here (a table in an area of a positioned page,
|
||||
// say) gets its instances, as in a flowed one, one under another.
|
||||
for instance in 0..self.instances(k, &ctx.som) {
|
||||
self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sib_index(&s.children, i) + instance)?;
|
||||
y += h;
|
||||
}
|
||||
}
|
||||
}
|
||||
Layout::LrTb | Layout::RlTb => {
|
||||
|
|
@ -1061,7 +1075,7 @@ impl Layouter<'_> {
|
|||
let ctx = ctx.with_som(&g.common, sib);
|
||||
// The group's data node holds the selected button's on value.
|
||||
let selected: Option<String> =
|
||||
self.data.and_then(|d| d.text_at(&som_to_path(&ctx.som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
self.data.and_then(|d| d.text_at(&data_path(self.tpl, &ctx.som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
let inner = rect.inset(&g.common.margin);
|
||||
let group = self.unique_name(g.common.name.as_deref().unwrap_or("group"));
|
||||
let nodes: Vec<Node> = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
|
||||
|
|
@ -1182,7 +1196,7 @@ impl Layouter<'_> {
|
|||
let field_name = f.common.name.clone().unwrap_or_else(|| "an image field".into());
|
||||
let text: Vec<u8> = self
|
||||
.data
|
||||
.and_then(|d| d.text_at(&som_to_path(&som_path())))
|
||||
.and_then(|d| d.text_at(&data_path(self.tpl, &som_path())))
|
||||
.map(|t| t.bytes().filter(|b| !b.is_ascii_whitespace()).collect())
|
||||
.unwrap_or_default();
|
||||
let from_data = if text.is_empty() {
|
||||
|
|
@ -1215,8 +1229,12 @@ impl Layouter<'_> {
|
|||
Ui::Barcode => {
|
||||
// The value as text, where the bars would be.
|
||||
self.warn_once("barcode fields show their value as text, not as bars");
|
||||
let value =
|
||||
self.data.and_then(|d| d.text_at(&som_to_path(&som_path()))).map(str::to_string).or_else(|| f.value.plain()).unwrap_or_default();
|
||||
let value = self
|
||||
.data
|
||||
.and_then(|d| d.text_at(&data_path(self.tpl, &som_path())))
|
||||
.map(str::to_string)
|
||||
.or_else(|| f.value.plain())
|
||||
.unwrap_or_default();
|
||||
if !value.trim().is_empty() {
|
||||
let mut para = fctx.para.clone();
|
||||
para.h_align = HAlign::Center;
|
||||
|
|
@ -1244,7 +1262,7 @@ impl Layouter<'_> {
|
|||
let read_only = matches!(f.access, Access::ReadOnly | Access::Protected);
|
||||
// The data's value wins over the template's default.
|
||||
let data_value: Option<String> =
|
||||
self.data.and_then(|d| d.value_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
// A choice list's default is a saved value too.
|
||||
let template_default = f.value.plain().map(|v| f.saved_value(v.trim())).filter(|v| !v.is_empty());
|
||||
let plain_value = data_value.clone().or_else(|| template_default.clone());
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ pub mod text;
|
|||
use pdfcraft_cos::Document;
|
||||
|
||||
pub use data::{
|
||||
DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, iso_to_pattern, parse_datasets, pattern_to_iso,
|
||||
read_values, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing,
|
||||
DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, data_path, iso_to_pattern, parse_datasets,
|
||||
pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets,
|
||||
write_datasets_reusing, write_datasets_with,
|
||||
};
|
||||
pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, layout};
|
||||
pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::collections::{BTreeMap, HashMap};
|
|||
|
||||
use pdfcraft_cos::{Dict, Document, Object, PdfString};
|
||||
|
||||
use crate::data::{DataNode, parse_datasets, som_to_path};
|
||||
use crate::data::{DataNode, data_path, parse_datasets};
|
||||
use crate::model::*;
|
||||
use crate::{Report, XfaError};
|
||||
|
||||
|
|
@ -171,6 +171,8 @@ fn child_som(parent: &str, name: Option<&str>, index: usize) -> String {
|
|||
}
|
||||
|
||||
struct Walker<'a> {
|
||||
/// For data paths (areas are not data scopes).
|
||||
tpl: &'a Template,
|
||||
data: Option<&'a DataNode>,
|
||||
ov: &'a Overrides,
|
||||
events: Vec<ScriptEvent>,
|
||||
|
|
@ -241,7 +243,7 @@ impl Walker<'_> {
|
|||
|
||||
fn field(&mut self, f: &Field, parent_som: &str, index: usize, radio: Option<&str>) -> FormNode {
|
||||
let som = child_som(parent_som, f.common.name.as_deref().or(Some("field")), index);
|
||||
let data_value = self.data.and_then(|d| d.value_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
let data_value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
let value = match (radio, data_value) {
|
||||
// A radio button's value is its on value when the group picked it.
|
||||
(Some(selected), _) => {
|
||||
|
|
@ -281,7 +283,7 @@ impl Walker<'_> {
|
|||
match n {
|
||||
Node::Subform(sf) => {
|
||||
let from_data = match (&sf.common.name, self.data) {
|
||||
(Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&som_to_path(parent_som), name),
|
||||
(Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name),
|
||||
_ => 0,
|
||||
};
|
||||
let instances = crate::layout::instance_count(&sf.occur, from_data);
|
||||
|
|
@ -344,7 +346,8 @@ impl Walker<'_> {
|
|||
}
|
||||
Node::ExclGroup(g) => {
|
||||
let som = child_som(parent_som, g.common.name.as_deref().or(Some("group")), sib);
|
||||
let selected = self.data.and_then(|d| d.text_at(&som_to_path(&som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
let selected =
|
||||
self.data.and_then(|d| d.text_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
self.push_events(&som, &g.scripts, None, None, None);
|
||||
let fields: Vec<Node> = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
|
||||
let mut children = Vec::new();
|
||||
|
|
@ -378,7 +381,7 @@ impl Walker<'_> {
|
|||
/// from the template and `ov`) and every scripted event, in document order, within
|
||||
/// [`MAX_FORM_NODES`] objects.
|
||||
pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> LiveForm {
|
||||
let mut w = Walker { data, ov, events: Vec::new(), nodes: 0, truncated: false };
|
||||
let mut w = Walker { tpl, data, ov, events: Vec::new(), nodes: 0, truncated: false };
|
||||
let root = Node::Subform(Box::new(tpl.root.clone()));
|
||||
let mut nodes = w.children(std::slice::from_ref(&root), "", 0);
|
||||
let mut root_node = nodes.pop().unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -1041,3 +1041,71 @@ fn pictures_are_embedded_once_across_relayouts_and_bad_data_pictures_are_reporte
|
|||
}
|
||||
assert!(find_image(&t.root.children).is_some_and(|n| n > 1_200_000));
|
||||
}
|
||||
|
||||
/// A repeating subform drawn inside positioned content (a top-to-bottom table in an area of a
|
||||
/// positioned subform) gets its `occur` instances, as in Adobe's viewers: real Designer forms
|
||||
/// have tables like this whose calculations read every row. Before, only one row was laid
|
||||
/// out, and the field after the table was placed as if there were one.
|
||||
#[test]
|
||||
fn repeating_subforms_inside_positioned_content_get_their_instances() {
|
||||
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="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
|
||||
<subform name="page1" w="7.5in" h="8in">
|
||||
<area name="box" x="0.5in" y="1in">
|
||||
<subform name="wrap" layout="tb" w="4in">
|
||||
<subform name="table" layout="tb" w="4in">
|
||||
<subform name="row" w="4in" h="0.3in"><occur min="3" max="-1"/>
|
||||
<field name="reason" w="4in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
</subform>
|
||||
</subform>
|
||||
<field name="after" w="4in" h="0.3in"><ui><textEdit/></ui></field>
|
||||
</subform>
|
||||
</area>
|
||||
</subform>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##;
|
||||
let form = layout_xml(xdp).unwrap();
|
||||
let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect();
|
||||
let rows: Vec<&Widget> = all.iter().copied().filter(|w| w.som.ends_with(".reason[0]")).collect();
|
||||
let soms: Vec<&str> = rows.iter().map(|w| w.som.as_str()).collect();
|
||||
assert_eq!(
|
||||
soms,
|
||||
[
|
||||
"form[0].page1[0].box[0].wrap[0].table[0].row[0].reason[0]",
|
||||
"form[0].page1[0].box[0].wrap[0].table[0].row[1].reason[0]",
|
||||
"form[0].page1[0].box[0].wrap[0].table[0].row[2].reason[0]",
|
||||
],
|
||||
"one widget per instance, each with its own SOM index"
|
||||
);
|
||||
// The rows are stacked, not drawn on top of each other…
|
||||
let step = rows[1].rect.y - rows[0].rect.y;
|
||||
assert!(step.abs() > 1.0, "rows overlap: {:?}", rows.iter().map(|w| w.rect).collect::<Vec<_>>());
|
||||
assert!(((rows[2].rect.y - rows[1].rect.y) - step).abs() < 0.01, "rows are evenly spaced");
|
||||
// …and what follows the table is placed after all of them (the table was measured with
|
||||
// its instances, not with one row).
|
||||
let after = all.iter().find(|w| w.som.ends_with(".after[0]")).expect("the field after the table");
|
||||
assert!(((after.rect.y - rows[2].rect.y) - step).abs() < 0.01, "after: {:?}, last row: {:?}", after.rect, rows[2].rect);
|
||||
}
|
||||
|
||||
/// Areas are named in SOM expressions but are not data scopes; unnamed subforms and areas have
|
||||
/// no segment at all. What the template doesn't have is kept as it is.
|
||||
#[test]
|
||||
fn data_paths_leave_areas_out() {
|
||||
let (tpl, _) = parse(&crate::fixtures::leave_template("")).unwrap();
|
||||
let path = |som: &str| data_path(&tpl, som).into_iter().map(|(n, i)| format!("{n}[{i}]")).collect::<Vec<_>>().join(".");
|
||||
// The area `box` (inside an unnamed subform) is dropped; indexes are kept.
|
||||
assert_eq!(path("form[0].page[0].box[0].table[0].row[2].days[0]"), "form[0].page[0].table[0].row[2].days[0]");
|
||||
assert_eq!(path("form[0].page[0].box[0].rest[0]"), "form[0].page[0].rest[0]");
|
||||
// No area on the way: as som_to_path.
|
||||
assert_eq!(path("form[0].page[0]"), "form[0].page[0]");
|
||||
// Unknown segments, past a leaf, or a SOM from another template: kept.
|
||||
assert_eq!(path("form[0].page[0].box[0].nothing[0].x[1]"), "form[0].page[0].nothing[0].x[1]");
|
||||
assert_eq!(path("form[0].page[0].box[0].rest[0].box[0]"), "form[0].page[0].rest[0].box[0]");
|
||||
assert_eq!(path("other[0].box[0].rest[0]"), "other[0].box[0].rest[0]");
|
||||
assert_eq!(data_path(&tpl, ""), Vec::new());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5142,7 +5142,7 @@ 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/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/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/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/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/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"]
|
||||
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/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/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/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/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/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"]
|
||||
notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF 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. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, 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. 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) and what open-time scripts changed is noted. Missing: 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; choosing a new picture for an image field; 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]]
|
||||
|
|
@ -5166,8 +5166,8 @@ milestone = "M12"
|
|||
status = "partial"
|
||||
commands = ["file.open", "file.save"]
|
||||
tools = ["doc_open", "form_fill", "doc_save"]
|
||||
evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally"]
|
||||
notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom). Missing: importing or exporting a standalone XML/XDP data file, explicit bind refs, data descriptions"
|
||||
evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"]
|
||||
notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Missing: importing or exporting a standalone XML/XDP data file, explicit bind refs, data descriptions"
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-flatten"
|
||||
|
|
|
|||
Loading…
Reference in New Issue