mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge #4608: M12: XFA repeating rows lay out past 50, and tables in positioned containers repeat their rows (#4607, #4615)
This commit is contained in:
commit
f0be783bd0
13 files changed
+770
-96
No files matched your search
@@ -7908,12 +7908,12 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
|
||||
assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 3, "{:?}", names(&s));
|
||||
assert_eq!(value(&s, "total"), vec!["15".to_string()], "qty 3 × price 5, calculated after the import: {:?}", s.take_js_output(id));
|
||||
assert_eq!(value(&s, "grand"), vec!["42.5".to_string()]);
|
||||
// More rows than the layout shows: the document says so.
|
||||
let rows: String = (0..60).map(|i| format!("<row><item>r{i}</item><amount>1</amount></row>")).collect();
|
||||
// More rows than the layout shows (1 000 for one repeating subform): the document says so.
|
||||
let rows: String = (0..1010).map(|i| format!("<row><item>r{i}</item><amount>1</amount></row>")).collect();
|
||||
let file = format!("<form><page1><qty>1</qty><table>{rows}</table></page1></form>");
|
||||
s.apply(id, Edit::ImportData { name: "many.xml".into(), bytes: Arc::new(file.into_bytes()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("60 rows")), "{out:?}");
|
||||
assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("1010 rows") && l.contains("first 1000")), "{out:?}");
|
||||
// A file with one row takes the others away.
|
||||
let file = "<form><page1><qty>1</qty><table><row><item>only</item><amount>7</amount></row></table></page1></form>";
|
||||
s.apply(id, Edit::ImportData { name: "one.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap();
|
||||
|
||||
@@ -11,3 +11,4 @@ mod ocr_edit;
|
||||
mod office_export_images;
|
||||
mod private_image_resize;
|
||||
mod private_text_glyphs;
|
||||
mod xfa_repeat;
|
||||
@@ -0,0 +1,83 @@
|
||||
//! A dynamic XFA form whose button adds rows past fifty lays every row out and adds the pages
|
||||
//! they need (XFA 3.3, "The occur element", p. 786: `max="-1"` sets no upper boundary).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_engine::Session;
|
||||
|
||||
/// A list whose `item` row (20pt, 36 to a page) repeats without bound, `data` rows in its
|
||||
/// datasets, and a button that adds `add` rows in one click.
|
||||
fn form(add: usize, data_rows: usize) -> Vec<u8> {
|
||||
let data: String = (1..=data_rows).map(|i| format!("<item><qty>row {i}</qty></item>")).collect();
|
||||
let datasets = if data_rows == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
r#"<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><page><list>{data}</list></page></form></xfa:data></xfa:datasets>"#
|
||||
)
|
||||
};
|
||||
let xdp = format!(
|
||||
r#"<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="p"><occur max="-1"/><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
<subform name="page" layout="tb">
|
||||
<field name="more" w="100pt" h="20pt"><ui><button/></ui><caption><value><text>Add rows</text></value></caption>
|
||||
<event activity="click"><script contentType="application/x-javascript">for (var i = 0; i < {add}; i++) list._item.addInstance(1);</script></event></field>
|
||||
<subform name="list" layout="tb">
|
||||
<subform name="item" layout="tb"><occur min="1" max="-1"/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
</subform>
|
||||
</subform>
|
||||
</subform></template>
|
||||
{datasets}
|
||||
</xdp:xdp>"#
|
||||
);
|
||||
pdfcraft_xfa::fixtures::shell(&xdp)
|
||||
}
|
||||
|
||||
fn rows(s: &Session, id: pdfcraft_engine::DocId) -> usize {
|
||||
s.get(id).map_or(0, |d| d.form.iter().filter(|f| f.name.starts_with("qty")).count())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_adding_rows_past_fifty_lays_them_all_out() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("rows.pdf", None, Arc::new(form(59, 0)), None).expect("opens");
|
||||
assert_eq!(rows(&s, id), 1);
|
||||
assert_eq!(s.get(id).map(|d| d.info.pages.len()), Some(1));
|
||||
let o = s.run_javascript(id, "", Some("more")).expect("the click runs");
|
||||
assert_eq!(o.error, None);
|
||||
// The button row plus 60 rows of 20pt: 61 rows at 36 a page is two pages.
|
||||
assert_eq!(rows(&s, id), 60);
|
||||
assert_eq!(s.get(id).map(|d| d.info.pages.len()), Some(2));
|
||||
let out = s.take_js_output(id);
|
||||
assert!(!out.console.iter().any(|l| l.contains("only the first")), "no row is cut: {:?}", out.console);
|
||||
}
|
||||
|
||||
/// A table (`layout="table"`) placed at its own position in a positioned subform, its `item` row
|
||||
/// repeating with `occur max` `max`, and five rows in its data (XFA 3.3, "Tables", p. 327).
|
||||
fn positioned_table(max: &str) -> Vec<u8> {
|
||||
let data: String = (1..=5).map(|i| format!("<item><qty>row {i}</qty></item>")).collect();
|
||||
let xdp = format!(
|
||||
r#"<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="p"><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
<subform name="page" w="540pt" h="700pt">
|
||||
<subform name="table" layout="table" columnWidths="200pt" x="36pt" y="72pt">
|
||||
<subform name="item" layout="row"><occur min="1" max="{max}"/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
</subform>
|
||||
</subform>
|
||||
</subform></template>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><page><table>{data}</table></page></form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>"#
|
||||
);
|
||||
pdfcraft_xfa::fixtures::shell(&xdp)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_in_a_positioned_subform_gets_a_row_per_data_row_up_to_its_max() {
|
||||
let mut s = Session::new();
|
||||
for (max, want) in [("-1", 5), ("3", 3)] {
|
||||
let id = s.open("table.pdf", None, Arc::new(positioned_table(max)), None).expect("opens");
|
||||
assert_eq!(rows(&s, id), want, "occur max {max}");
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,10 @@ that warning and keeps the existing layout and accepted widget values.
|
||||
- **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data
|
||||
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
|
||||
repeating subform or table row gets as many instances as the data has (in flowed and positioned
|
||||
containers alike), up to its `occur max` and at
|
||||
most 1 000 (`MAX_INSTANCES`, a work bound; the rows past it are left out with a layout
|
||||
warning). `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
|
||||
unbound data, other namespaces, attributes and comments stay byte for byte. Values it can't
|
||||
|
||||
@@ -52,8 +52,17 @@ const MAX_MEASURES: usize = 2_000_000;
|
||||
const MAX_DRAW_CHARS: usize = 256 << 20;
|
||||
#[cfg(test)]
|
||||
const MAX_DRAW_CHARS: usize = 1 << 20;
|
||||
/// Most initial instances of one repeating subform.
|
||||
const MAX_INSTANCES: usize = 50;
|
||||
/// Most instances of one repeating subform laid out (a row the data adds, or the template's
|
||||
/// initial count). `occur max="-1"` sets no upper boundary (XFA 3.3, "The occur element",
|
||||
/// p. 786), so this only bounds the work of a hostile or runaway form: a long list or table
|
||||
/// is laid out whole up to here, and the rows past it are reported ([`Layouter::warn_truncated`]).
|
||||
/// It matches the largest `occur` count the parser reads and the most rows one script run may
|
||||
/// add (`MAX_ADDED` in pdfcraft-js). Laying out and listing the fields grows faster than the
|
||||
/// row count (one text field a row: 1,000 rows open in about 3 s in a debug build, 2,000 in
|
||||
/// about 13 s), so a much larger bound would let one list stall the app. The layout budgets
|
||||
/// ([`MAX_MEASURES`], [`MAX_ITEMS`], [`MAX_PAGES`]) still bound the form as a whole, however
|
||||
/// its repeats nest.
|
||||
pub(crate) const MAX_INSTANCES: usize = 1_000;
|
||||
/// Largest picture taken from the data (decoded bytes), as for template pictures.
|
||||
const MAX_DATA_IMAGE: usize = 32 << 20;
|
||||
/// Most picture bytes one layout keeps, all copies together. Each laid-out picture holds its
|
||||
@@ -90,8 +99,9 @@ impl Ctx {
|
||||
}
|
||||
}
|
||||
|
||||
/// (address of a subform or area in the template, available width bits, depth).
|
||||
type MeasureKey = (usize, u64, usize);
|
||||
/// (address of a subform or area in the template, available width bits, depth, SOM path of the
|
||||
/// instance measured when data drives the layout).
|
||||
type MeasureKey = (usize, u64, usize, String);
|
||||
|
||||
/// A cached size, with the inherited text defaults it was measured with.
|
||||
struct Measured {
|
||||
|
||||
@@ -48,7 +48,7 @@ impl Layouter<'_> {
|
||||
if !c.presence.occupies() {
|
||||
return Ok(());
|
||||
}
|
||||
let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1);
|
||||
let (w, h) = self.size_at(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1, sib);
|
||||
let (x, y) = c.top_left(w, h);
|
||||
let rect = Rect::new(inner.x + x, inner.y + y, w, h);
|
||||
let start = self.item_count();
|
||||
@@ -143,18 +143,25 @@ impl Layouter<'_> {
|
||||
if !k.common().presence.occupies() {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1);
|
||||
// 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, sibs.at(i) + instance)?;
|
||||
// say) gets its instances, as in a flowed one, one under another,
|
||||
// each as tall as its own content, and the rows past them are
|
||||
// reported as there.
|
||||
let instances = self.laid_out_instances(k, &ctx.som);
|
||||
for instance in 0..instances {
|
||||
let sib = sibs.at(i).saturating_add(instance);
|
||||
let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, sib);
|
||||
self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sib)?;
|
||||
y += h;
|
||||
}
|
||||
}
|
||||
}
|
||||
Layout::LrTb | Layout::RlTb => {
|
||||
// A repeating subform here (rows of fields under a heading) gets its
|
||||
// instances, as in the other layouts.
|
||||
// instances, as in the other layouts, and the rows past them are reported.
|
||||
for k in s.children.iter().filter(|k| k.common().presence.occupies()) {
|
||||
self.laid_out_instances(k, &ctx.som);
|
||||
}
|
||||
let (placed, _) = self.pack_instances(&s.children, inner.w, &ctx, depth, &ctx.som);
|
||||
for (i, instance, r) in placed {
|
||||
if let Some(k) = s.children.get(i) {
|
||||
@@ -174,11 +181,16 @@ impl Layouter<'_> {
|
||||
if let Node::Subform(r) = k
|
||||
&& r.layout == Layout::Row
|
||||
{
|
||||
let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1);
|
||||
self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib)?;
|
||||
y += h;
|
||||
// A repeating row (a table in a positioned subform, say) gets its
|
||||
// instances one under another, as in a flowed table (XFA 3.3,
|
||||
// "Tables", p. 327), and the rows past them are reported.
|
||||
for instance in 0..self.laid_out_instances(k, &ctx.som) {
|
||||
let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1, csib.saturating_add(instance));
|
||||
self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib + instance)?;
|
||||
y += h;
|
||||
}
|
||||
} else {
|
||||
let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1);
|
||||
let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, csib);
|
||||
self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, csib)?;
|
||||
y += h;
|
||||
}
|
||||
@@ -346,6 +358,8 @@ impl Layouter<'_> {
|
||||
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();
|
||||
// Each field is measured under the SOM index it is drawn at.
|
||||
let sibs = SomIndices::of(&nodes);
|
||||
let placed: Vec<(usize, Rect)> = match g.layout {
|
||||
Layout::Tb => {
|
||||
let mut y = 0.0;
|
||||
@@ -354,7 +368,7 @@ impl Layouter<'_> {
|
||||
if !k.common().presence.occupies() {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1);
|
||||
let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, sibs.at(i));
|
||||
out.push((i, Rect::new(0.0, y, w, h)));
|
||||
y += h;
|
||||
}
|
||||
@@ -366,14 +380,13 @@ impl Layouter<'_> {
|
||||
.filter(|(_, k)| k.common().presence.occupies())
|
||||
.map(|(i, k)| {
|
||||
let c = k.common();
|
||||
let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1);
|
||||
let (w, h) = self.size_at(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1, sibs.at(i));
|
||||
let (x, y) = c.top_left(w, h);
|
||||
(i, Rect::new(x, y, w, h))
|
||||
})
|
||||
.collect(),
|
||||
_ => self.pack(&nodes, inner.w, &ctx, depth).0,
|
||||
};
|
||||
let sibs = SomIndices::of(&nodes);
|
||||
for (i, r) in placed {
|
||||
if let Some(f) = g.fields.get(i) {
|
||||
// Each button shows where it and the group say ([`Common::views`]), as any field
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::common::{WidgetRest, clamp_opt, divide, is_row, moved, solid_extent, split_line, split_widget, splittable, widget_box};
|
||||
use super::items::Item;
|
||||
use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Node, Rect, SomIndices, Splitting, Subform, XfaError, data_path, instance_count};
|
||||
use super::{
|
||||
Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_INSTANCES, MAX_MEASURES, Node, Rect, SomIndices, Splitting, Subform, XfaError, data_path,
|
||||
instance_count,
|
||||
};
|
||||
|
||||
impl Layouter<'_> {
|
||||
/// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits,
|
||||
@@ -328,8 +331,7 @@ impl Layouter<'_> {
|
||||
self.break_frames(0.0, true)?;
|
||||
}
|
||||
// Data adds instances of a repeating subform (rows added in another viewer).
|
||||
let instances = self.instances(node, &ctx.som);
|
||||
self.warn_truncated(node, &ctx.som, instances);
|
||||
let instances = self.laid_out_instances(node, &ctx.som);
|
||||
let ctx_parent = ctx;
|
||||
for i in 0..instances {
|
||||
// Every instance spends budget, so nested repeats that place nothing still stop.
|
||||
@@ -341,7 +343,7 @@ impl Layouter<'_> {
|
||||
let w = clamp_opt(sf.common.w.unwrap_or(w), sf.common.min_w, sf.common.max_w).min(w.max(1.0));
|
||||
if !matches!(sf.layout, Layout::Tb | Layout::Table) || sf.common.h.is_some() {
|
||||
// Not splittable: one block.
|
||||
let (_, h) = self.size_of(node, w, ctx_parent, depth);
|
||||
let (_, h) = self.size_at(node, w, ctx_parent, depth, sib + i);
|
||||
self.flow_block(node, x, (w, h), ctx_parent, depth, sib + i)?;
|
||||
continue;
|
||||
}
|
||||
@@ -372,8 +374,7 @@ impl Layouter<'_> {
|
||||
self.flow_subform(child, Some((inner_x, inner_w)), &ctx, depth + 1, csib)?;
|
||||
continue;
|
||||
}
|
||||
let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1);
|
||||
self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?;
|
||||
self.flow_instances(child, inner_x, inner_w, &ctx, depth + 1, csib)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -405,8 +406,8 @@ impl Layouter<'_> {
|
||||
Some(Node::Subform(l)) => Some((li, l.as_ref())),
|
||||
_ => None,
|
||||
});
|
||||
let lead_h = lead.map_or(0.0, |(_, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1));
|
||||
let sibs = SomIndices::of(&sf.children);
|
||||
let lead_h = lead.map_or(0.0, |(li, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1, sibs.at(li)));
|
||||
for (ci, child) in sf.children.iter().enumerate() {
|
||||
if !child.common().presence.occupies() {
|
||||
continue;
|
||||
@@ -416,10 +417,9 @@ impl Layouter<'_> {
|
||||
&& row.layout == Layout::Row
|
||||
{
|
||||
// Rows added as data (in another viewer) repeat the row.
|
||||
let instances = self.instances(child, &ctx.som);
|
||||
self.warn_truncated(child, &ctx.som, instances);
|
||||
let instances = self.laid_out_instances(child, &ctx.som);
|
||||
for inst in 0..instances {
|
||||
let h = self.row_height(row, &cols, inner_w, ctx, depth + 1);
|
||||
let h = self.row_height(row, &cols, inner_w, ctx, depth + 1, csib.saturating_add(inst));
|
||||
let led = lead.filter(|(li, _)| *li != ci);
|
||||
if self.make_room(h, led.map_or(0.0, |_| lead_h))?
|
||||
&& let Some((li, l)) = led
|
||||
@@ -440,8 +440,23 @@ impl Layouter<'_> {
|
||||
self.flow_subform(child, Some((x, inner_w)), ctx, depth + 1, csib)?;
|
||||
continue;
|
||||
}
|
||||
let (cw, ch) = self.size_of(child, inner_w, ctx, depth + 1);
|
||||
self.flow_block(child, x, (cw, ch), ctx, depth + 1, csib)?;
|
||||
self.flow_instances(child, x, inner_w, ctx, depth + 1, csib)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flow `node`, which isn't split across content areas, as one block per instance (a
|
||||
/// repeating positioned subform gets one for each row the data or its `occur` gives it, XFA
|
||||
/// 3.3, "Repeating Elements using Occurrence Limits", p. 263), each as tall as its own
|
||||
/// content; the data rows past them are reported.
|
||||
fn flow_instances(&mut self, node: &Node, x: f64, inner_w: f64, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> {
|
||||
for instance in 0..self.laid_out_instances(node, &ctx.som) {
|
||||
// Every instance spends budget, so nested repeats that place nothing still stop.
|
||||
self.measures.set(self.measures.get().saturating_add(1));
|
||||
self.check_measures()?;
|
||||
let sib = sib.saturating_add(instance);
|
||||
let size = self.size_at(node, inner_w, ctx, depth, sib);
|
||||
self.flow_block(node, x, size, ctx, depth, sib)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -462,19 +477,35 @@ impl Layouter<'_> {
|
||||
instance_count(&sf.occur, from_data)
|
||||
}
|
||||
|
||||
/// [`Self::instances`] for laying `node` out: the count every layout that repeats a subform
|
||||
/// draws (flowed or positioned, top to bottom, wrapped or a table's rows), with the data rows
|
||||
/// past it reported ([`Self::warn_truncated`]).
|
||||
pub(super) fn laid_out_instances(&mut self, node: &Node, parent_som: &str) -> usize {
|
||||
let instances = self.instances(node, parent_som);
|
||||
self.warn_truncated(node, parent_som, instances);
|
||||
instances
|
||||
}
|
||||
|
||||
/// The data holds more instances of `node` than `laid_out`: say so, since calculations
|
||||
/// then see only those.
|
||||
fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) {
|
||||
/// then see only those, and say why. (The template's own counts can't ask for more: the
|
||||
/// parser reads `initial` and `max` up to [`MAX_INSTANCES`].)
|
||||
pub(super) fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) {
|
||||
let Node::Subform(sf) = node else { return };
|
||||
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,
|
||||
};
|
||||
if from_data > laid_out {
|
||||
self.warn_once(format!(
|
||||
"{}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those)",
|
||||
sf.common.name.as_deref().unwrap_or("a subform")
|
||||
));
|
||||
if from_data <= laid_out {
|
||||
return;
|
||||
}
|
||||
let name = sf.common.name.as_deref().unwrap_or("a subform");
|
||||
// The template's own `occur max` is the form's rule: past it, the extra data is ignored
|
||||
// (XFA 3.3, p. 336). Only the layout's own limit needs naming as a limit.
|
||||
let why = if sf.occur.max.is_some_and(|m| m <= laid_out) {
|
||||
String::new()
|
||||
} else {
|
||||
format!("; {MAX_INSTANCES} is the most one repeating subform lays out")
|
||||
};
|
||||
self.warn_once(format!("{name}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those){why}"));
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,42 @@
|
||||
use super::common::clamp_opt;
|
||||
use super::field::auto_check_reserve;
|
||||
use super::{
|
||||
Access, Area, Ctx, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Measured, Node, Para, Placement, Rect, Subform, Ui,
|
||||
Value, data_path, text,
|
||||
Access, Area, Ctx, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Measured, Node, Para, Placement, Rect, SomIndices,
|
||||
Subform, Ui, Value, data_path, text,
|
||||
};
|
||||
|
||||
impl Layouter<'_> {
|
||||
/// The width and height `node` takes in `avail_w`: its own size, else what its content needs
|
||||
/// (containers are measured once per width and depth, and measuring stops at its caps).
|
||||
pub(super) fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) {
|
||||
/// The width and height instance `index` of `node` under `ctx` takes in `avail_w`: its own
|
||||
/// size, else what its content needs (containers are measured once per width, depth and
|
||||
/// instance, and measuring stops at its caps). Instances differ when the data gives each its
|
||||
/// own rows (XFA 3.3, "Tables", p. 327; Chapter 9, p. 336), so with data each is measured
|
||||
/// under its own SOM path.
|
||||
pub(super) fn size_at(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize, index: usize) -> (f64, f64) {
|
||||
let c = node.common();
|
||||
let spent = self.measures.get().saturating_add(1);
|
||||
self.measures.set(spent);
|
||||
if depth > MAX_DEPTH || spent > MAX_MEASURES {
|
||||
return (c.w.unwrap_or(avail_w), c.h.unwrap_or(0.0));
|
||||
}
|
||||
let own = ctx.with_som(c, index);
|
||||
// Only containers recurse; their addresses are stable for the whole layout (the
|
||||
// template is borrowed, the root and page areas are held by the layouter).
|
||||
// template is borrowed, the root and page areas are held by the layouter). Without data
|
||||
// every instance is alike, so one measurement serves them all.
|
||||
let som = || if self.data.is_some() { own.som.clone() } else { String::new() };
|
||||
let key = match node {
|
||||
Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth)),
|
||||
Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth)),
|
||||
Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth, som())),
|
||||
Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth, som())),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(key) = key
|
||||
if let Some(key) = &key
|
||||
&& let Ok(cache) = self.measured.try_borrow()
|
||||
&& let Some(m) = cache.get(&key)
|
||||
&& let Some(m) = cache.get(key)
|
||||
&& m.font == ctx.font
|
||||
&& m.para == ctx.para
|
||||
{
|
||||
return m.size;
|
||||
}
|
||||
let size = self.measure(node, avail_w, ctx, depth);
|
||||
let size = self.measure(node, avail_w, &own, depth);
|
||||
if let Some(key) = key
|
||||
&& let Ok(mut cache) = self.measured.try_borrow_mut()
|
||||
{
|
||||
@@ -39,15 +45,13 @@ impl Layouter<'_> {
|
||||
size
|
||||
}
|
||||
|
||||
/// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_of`] caches it).
|
||||
/// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_at`] caches it).
|
||||
/// Without a width, a container, a text draw or a button is as wide as its content (at most
|
||||
/// `avail_w`) and anything else takes `avail_w`; without a height, it is as tall as its content.
|
||||
/// `ctx` holds the SOM path of the instance measured: its repeating children count their
|
||||
/// instances under it, and a growing text field finds its value at it.
|
||||
fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) {
|
||||
let c = node.common();
|
||||
// The node's own SOM: its repeating children count their instances under it, and a
|
||||
// growing text field finds its value at it. Measurements are cached per template node,
|
||||
// so instance 0 stands for all of them.
|
||||
let ctx = ctx.with_som(c, 0);
|
||||
let som = ctx.som.clone();
|
||||
// A container without a width is as wide as its content (a radio group beside its
|
||||
// question, say), and so is a text label or a button (as wide as its caption), within
|
||||
@@ -58,7 +62,7 @@ impl Layouter<'_> {
|
||||
None => match node {
|
||||
Node::Subform(_) | Node::ExclGroup(_) | Node::Area(_) => {
|
||||
let inner = (avail_w - c.margin.horizontal()).max(1.0);
|
||||
(self.content_width(node, inner, &ctx, depth) + c.margin.horizontal()).min(avail_w)
|
||||
(self.content_width(node, inner, ctx, depth) + c.margin.horizontal()).min(avail_w)
|
||||
}
|
||||
Node::Draw(d) => self.natural_width(&d.value, &ctx.font, &ctx.para).map_or(avail_w, |t| (t + c.margin.horizontal()).min(avail_w)),
|
||||
Node::Field(f) if f.ui == Ui::Button => f
|
||||
@@ -66,7 +70,7 @@ impl Layouter<'_> {
|
||||
.as_ref()
|
||||
.and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para)))
|
||||
.map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)),
|
||||
Node::Field(f) if f.ui == Ui::CheckButton => (self.check_width(f, &ctx) + c.margin.horizontal()).min(avail_w),
|
||||
Node::Field(f) if f.ui == Ui::CheckButton => (self.check_width(f, ctx) + c.margin.horizontal()).min(avail_w),
|
||||
Node::Field(_) => avail_w,
|
||||
},
|
||||
};
|
||||
@@ -76,32 +80,41 @@ impl Layouter<'_> {
|
||||
None => {
|
||||
let inner_w = (w - c.margin.horizontal()).max(1.0);
|
||||
let content = match node {
|
||||
Node::Draw(d) => self.value_height(&d.value, inner_w, &ctx),
|
||||
Node::Field(f) => self.field_height(f, inner_w, &ctx),
|
||||
Node::ExclGroup(g) => self.group_height(g, inner_w, &ctx, depth),
|
||||
Node::Area(a) => self.positioned_height(&a.children, inner_w, &ctx, depth),
|
||||
Node::Draw(d) => self.value_height(&d.value, inner_w, ctx),
|
||||
Node::Field(f) => self.field_height(f, inner_w, ctx),
|
||||
Node::ExclGroup(g) => self.group_height(g, inner_w, ctx, depth),
|
||||
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),
|
||||
// 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.pack_instances(&s.children, inner_w, &ctx, depth, &som).1,
|
||||
Layout::Table => {
|
||||
let cols = self.columns(s, inner_w);
|
||||
Layout::Positioned => self.positioned_height(&s.children, inner_w, ctx, depth),
|
||||
// Each instance of a repeating child takes its own height.
|
||||
Layout::Tb => {
|
||||
let sibs = SomIndices::of(&s.children);
|
||||
s.children
|
||||
.iter()
|
||||
.filter(|k| k.common().presence.occupies())
|
||||
.map(|k| match k {
|
||||
Node::Subform(r) if r.layout == Layout::Row => self.row_height(r, &cols, inner_w, &ctx, depth + 1),
|
||||
_ => self.size_of(k, inner_w, &ctx, depth + 1).1,
|
||||
.enumerate()
|
||||
.filter(|(_, k)| k.common().presence.occupies())
|
||||
.map(|(i, k)| self.instances_height(k, inner_w, ctx, depth + 1, &som, sibs.at(i)))
|
||||
.sum()
|
||||
}
|
||||
Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, ctx, depth, &som).1,
|
||||
Layout::Table => {
|
||||
let cols = self.columns(s, inner_w);
|
||||
let sibs = SomIndices::of(&s.children);
|
||||
s.children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, k)| k.common().presence.occupies())
|
||||
.map(|(i, k)| match k {
|
||||
// Each instance of a repeating row takes its own height, as drawn.
|
||||
Node::Subform(r) if r.layout == Layout::Row => (0..self.instances(k, &som))
|
||||
.map(|n| self.row_height(r, &cols, inner_w, ctx, depth + 1, sibs.at(i).saturating_add(n)))
|
||||
.sum(),
|
||||
_ => self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i)).1,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
Layout::Row => self.row_height(s, &self.columns(s, inner_w), inner_w, &ctx, depth + 1),
|
||||
// `ctx` is already this row instance's own.
|
||||
Layout::Row => self.cells(s, &self.columns(s, inner_w), inner_w, ctx, depth + 1).1,
|
||||
},
|
||||
};
|
||||
content + c.margin.vertical()
|
||||
@@ -116,6 +129,12 @@ impl Layouter<'_> {
|
||||
(w, clamp_opt(h, min_h, c.max_h))
|
||||
}
|
||||
|
||||
/// The height `k` takes with all its instances under the container at `som`, one under
|
||||
/// another, the first at SOM index `sib` among its siblings: each instance takes its own.
|
||||
fn instances_height(&self, k: &Node, inner_w: f64, ctx: &Ctx, depth: usize, som: &str, sib: usize) -> f64 {
|
||||
(0..self.instances(k, som)).map(|i| self.size_at(k, inner_w, ctx, depth, sib.saturating_add(i)).1).sum()
|
||||
}
|
||||
|
||||
/// The natural width of a container's content within `avail_w`.
|
||||
fn content_width(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> f64 {
|
||||
let group: Vec<Node>;
|
||||
@@ -134,20 +153,24 @@ impl Layouter<'_> {
|
||||
Node::Area(a) => (&a.children, Layout::Positioned),
|
||||
_ => return avail_w,
|
||||
};
|
||||
let visible = children.iter().filter(|k| k.common().presence.occupies());
|
||||
// Each child under the SOM index it is drawn at.
|
||||
let sibs = SomIndices::of(children);
|
||||
let visible = children.iter().enumerate().filter(|(_, k)| k.common().presence.occupies());
|
||||
match layout {
|
||||
Layout::LrTb | Layout::RlTb | Layout::Row => {
|
||||
let sum: f64 = visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).sum();
|
||||
let sum: f64 = visible.map(|(i, k)| self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i)).0).sum();
|
||||
if sum <= avail_w + 0.05 { sum } else { avail_w }
|
||||
}
|
||||
Layout::Positioned => visible
|
||||
.map(|k| {
|
||||
let (w, h) = self.size_of(k, avail_w, ctx, depth + 1);
|
||||
.map(|(i, k)| {
|
||||
let (w, h) = self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i));
|
||||
k.standing_box(w, h).right()
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
.min(avail_w),
|
||||
Layout::Tb | Layout::Table => visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).fold(0.0, f64::max).min(avail_w),
|
||||
Layout::Tb | Layout::Table => {
|
||||
visible.map(|(i, k)| self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i)).0).fold(0.0, f64::max).min(avail_w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,19 +327,29 @@ impl Layouter<'_> {
|
||||
fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 {
|
||||
let nodes: Vec<Node> = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
|
||||
match g.layout {
|
||||
Layout::Tb => nodes.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, ctx, depth + 1).1).sum(),
|
||||
Layout::Tb => {
|
||||
let sibs = SomIndices::of(&nodes);
|
||||
nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, k)| k.common().presence.occupies())
|
||||
.map(|(i, k)| self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i)).1)
|
||||
.sum()
|
||||
}
|
||||
Layout::Positioned => self.positioned_height(&nodes, inner_w, ctx, depth),
|
||||
_ => self.packed_height(&nodes, inner_w, ctx, depth),
|
||||
}
|
||||
}
|
||||
|
||||
fn positioned_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 {
|
||||
let sibs = SomIndices::of(children);
|
||||
children
|
||||
.iter()
|
||||
.filter(|k| k.common().presence.occupies())
|
||||
.map(|k| {
|
||||
.enumerate()
|
||||
.filter(|(_, k)| k.common().presence.occupies())
|
||||
.map(|(i, k)| {
|
||||
let c = k.common();
|
||||
let (w, h) = self.size_of(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1);
|
||||
let (w, h) = self.size_at(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1, sibs.at(i));
|
||||
k.standing_box(w, h).bottom()
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
@@ -327,13 +360,14 @@ impl Layouter<'_> {
|
||||
/// container at `som`.
|
||||
pub(super) fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec<(usize, usize, Rect)>, f64) {
|
||||
let mut out = Vec::new();
|
||||
let sibs = SomIndices::of(children);
|
||||
let (mut x, mut y, mut line_h) = (0.0, 0.0, 0.0);
|
||||
for (i, k) in children.iter().enumerate() {
|
||||
if !k.common().presence.occupies() {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = self.size_of(k, inner_w, ctx, depth + 1);
|
||||
for instance in 0..self.instances(k, som) {
|
||||
let (w, h) = self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i).saturating_add(instance));
|
||||
if x > 0.0 && x + w > inner_w + 0.05 {
|
||||
y += line_h;
|
||||
x = 0.0;
|
||||
@@ -357,20 +391,24 @@ impl Layouter<'_> {
|
||||
self.pack(children, inner_w, ctx, depth).1
|
||||
}
|
||||
|
||||
/// Cell rectangles of a row (relative to the row's inner box) and the row height.
|
||||
/// Cell rectangles of a row (relative to the row's inner box) and the row height. Each cell
|
||||
/// is measured under its own SOM index among its same-named siblings (`qty[1]`, not
|
||||
/// `qty[0]`), the index [`Self::draw_row`] draws it at, so a cell that grows with its value
|
||||
/// takes its own value's height.
|
||||
pub(super) fn cells(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) {
|
||||
let ctx = ctx.child(&row.common);
|
||||
let mut out = Vec::new();
|
||||
let mut col = 0usize;
|
||||
let mut x = 0.0;
|
||||
let mut h: f64 = 0.0;
|
||||
let sibs = SomIndices::of(&row.children);
|
||||
for (i, k) in row.children.iter().enumerate() {
|
||||
if !k.common().presence.occupies() {
|
||||
continue;
|
||||
}
|
||||
let span = k.common().col_span.max(1);
|
||||
let w = if col < cols.len() { cols.iter().skip(col).take(span).sum::<f64>() } else { k.common().w.unwrap_or((inner_w - x).max(1.0)) };
|
||||
let (_, ch) = self.size_of(k, w, &ctx, depth + 1);
|
||||
let (_, ch) = self.size_at(k, w, &ctx, depth + 1, sibs.at(i));
|
||||
out.push((i, Rect::new(x, 0.0, w, ch)));
|
||||
h = h.max(ch);
|
||||
x += w;
|
||||
@@ -383,7 +421,10 @@ impl Layouter<'_> {
|
||||
(out, clamp_opt(h, row.common.min_h, row.common.max_h))
|
||||
}
|
||||
|
||||
pub(super) fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 {
|
||||
self.cells(row, cols, inner_w, ctx, depth).1
|
||||
/// The height of instance `sib` of `row` in the table at `ctx`, measured as [`Self::draw_row`]
|
||||
/// draws it: under the instance's own SOM path, so a cell that grows with its text takes
|
||||
/// that instance's value (XFA 3.3, "Tables", p. 327; "Growable Containers", p. 275).
|
||||
pub(super) fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize, sib: usize) -> f64 {
|
||||
self.cells(row, cols, inner_w, &ctx.with_som(&row.common, sib), depth).1
|
||||
}
|
||||
}
|
||||
@@ -176,5 +176,11 @@ pub fn render_into(doc: &mut Document) -> Result<Report, XfaError> {
|
||||
Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod repeat_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod table_repeat_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Repeating subforms with many instances: every row the data holds is laid out, up to the
|
||||
//! template's `occur max` and the layout's instance limit, past which the rows left out are
|
||||
//! reported (XFA 3.3, "The occur element", p. 786; Chapter 9, p. 336).
|
||||
|
||||
use super::*;
|
||||
use crate::layout::MAX_INSTANCES;
|
||||
use crate::model::Occur;
|
||||
|
||||
/// A form with a flowed list (`table` false) or a table (`table` true) whose `item` row
|
||||
/// repeats with `occur max`, and data holding `rows` rows, each 20pt tall: 36 to a page.
|
||||
fn rows_form(max: &str, rows: usize, table: bool) -> String {
|
||||
let (list, row) = if table { (r#"layout="table" columnWidths="200pt""#, r#"layout="row""#) } else { (r#"layout="tb""#, r#"layout="tb""#) };
|
||||
let data: String = (1..=rows).map(|i| format!("<item><qty>row {i}</qty></item>")).collect();
|
||||
format!(
|
||||
r#"<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="p"><occur max="-1"/><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
<subform name="list" {list}>
|
||||
<subform name="item" {row}><occur min="1" max="{max}"/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
</subform>
|
||||
</subform></template>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><list>{data}</list></form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn qty_widgets(pages: &[Page]) -> Vec<&Widget> {
|
||||
pages.iter().flat_map(|p| p.items.iter()).filter_map(|it| if let Item::Widget(w) = it { Some(w.as_ref()) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// The values of the laid-out `qty` fields, in order.
|
||||
fn values(form: &Form) -> Vec<String> {
|
||||
qty_widgets(&form.pages).iter().map(|w| w.value.clone().unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
fn expected(n: usize) -> Vec<String> {
|
||||
(1..=n).map(|i| format!("row {i}")).collect()
|
||||
}
|
||||
|
||||
fn cut_warning(form: &Form) -> Option<&String> {
|
||||
form.warnings.iter().find(|w| w.contains("rows; only the first"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_data_row_past_fifty_is_laid_out_and_paginated() {
|
||||
for table in [false, true] {
|
||||
for (rows, pages) in [(60, 2), (300, 9)] {
|
||||
let form = layout_xml(&rows_form("-1", rows, table)).unwrap();
|
||||
assert_eq!(values(&form), expected(rows), "table {table}, {rows} rows");
|
||||
assert_eq!(form.pages.len(), pages, "table {table}, {rows} rows at 36 a page");
|
||||
assert_eq!(cut_warning(&form), None, "nothing is cut: {:?}", form.warnings);
|
||||
// Rows stay inside the content area of their page.
|
||||
for w in qty_widgets(&form.pages) {
|
||||
assert!(w.rect.y >= 36.0 - 0.01 && w.rect.bottom() <= 756.0 + 0.01, "{} at {:?}", w.name, w.rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occur_max_still_stops_the_rows_and_says_so() {
|
||||
for table in [false, true] {
|
||||
let form = layout_xml(&rows_form("5", 60, table)).unwrap();
|
||||
assert_eq!(values(&form), expected(5), "table {table}");
|
||||
let w = cut_warning(&form).unwrap_or_else(|| panic!("the 55 rows past occur max are reported: {:?}", form.warnings));
|
||||
assert!(w.contains("holds 60 rows") && w.contains("first 5") && !w.contains("the most"), "occur max is the form's own rule: {w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_past_the_instance_limit_are_cut_with_a_warning() {
|
||||
for table in [false, true] {
|
||||
// Exactly at the limit: all laid out, nothing reported.
|
||||
let form = layout_xml(&rows_form("-1", MAX_INSTANCES, table)).unwrap();
|
||||
assert_eq!(values(&form).len(), MAX_INSTANCES, "table {table}");
|
||||
assert_eq!(cut_warning(&form), None, "{:?}", form.warnings);
|
||||
// One past it: the last row is left out, and the warning says so.
|
||||
let form = layout_xml(&rows_form("-1", MAX_INSTANCES + 1, table)).unwrap();
|
||||
assert_eq!(values(&form), expected(MAX_INSTANCES), "table {table}");
|
||||
let w = cut_warning(&form).unwrap_or_else(|| panic!("rows past the limit are reported: {:?}", form.warnings));
|
||||
assert!(w.contains(&format!("holds {} rows", MAX_INSTANCES + 1)) && w.contains(&format!("first {MAX_INSTANCES}")), "{w}");
|
||||
assert!(w.contains(&format!("{MAX_INSTANCES} is the most one repeating subform lays out")), "the limit is named: {w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_initial_count_lays_out_whole_up_to_the_limit() {
|
||||
// `initial` comes from the template, not the data: past fifty it lays out whole, and a
|
||||
// hostile count stops at the limit (the parser reads it up to there).
|
||||
let tpl = |initial: &str| {
|
||||
rows_form("-1", 0, false).replace(r#"<occur min="1" max="-1"/>"#, &format!(r#"<occur min="1" initial="{initial}" max="-1"/>"#))
|
||||
};
|
||||
let form = layout_xml(&tpl("300")).unwrap();
|
||||
assert_eq!(qty_widgets(&form.pages).len(), 300);
|
||||
assert_eq!(form.pages.len(), 9);
|
||||
let form = layout_xml(&tpl("1000000000")).unwrap();
|
||||
assert_eq!(qty_widgets(&form.pages).len(), MAX_INSTANCES);
|
||||
assert_eq!(crate::layout::instance_count(&Occur { initial: usize::MAX, ..Occur::default() }, 0), 1, "max defaults to 1");
|
||||
let unbounded = Occur { initial: usize::MAX, max: None, ..Occur::default() };
|
||||
assert_eq!(crate::layout::instance_count(&unbounded, 0), MAX_INSTANCES);
|
||||
assert_eq!(crate::layout::instance_count(&unbounded, usize::MAX), MAX_INSTANCES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_cut_in_a_positioned_container_are_reported_too() {
|
||||
// The list sits in a positioned subform, so it is drawn rather than flowed: its `tb`,
|
||||
// `lr-tb` and `rl-tb` repeats are cut at the same bounds and must say so as a flowed one does.
|
||||
for layout in ["tb", "lr-tb", "rl-tb"] {
|
||||
let xml = rows_form("5", 60, false)
|
||||
.replace(r#"<subform name="form" layout="tb">"#, r#"<subform name="form" layout="tb"><subform name="page" w="540pt" h="720pt">"#)
|
||||
.replace("</subform></template>", "</subform></subform></template>")
|
||||
.replace(r#"<subform name="list" layout="tb">"#, &format!(r#"<subform name="list" x="0pt" y="0pt" w="540pt" layout="{layout}">"#))
|
||||
.replace("<xfa:data><form><list>", "<xfa:data><form><page><list>")
|
||||
.replace("</list></form></xfa:data>", "</list></page></form></xfa:data>");
|
||||
let form = layout_xml(&xml).unwrap();
|
||||
assert_eq!(values(&form), expected(5), "{layout}");
|
||||
let w = cut_warning(&form).unwrap_or_else(|| panic!("{layout}: the rows past occur max are reported: {:?}", form.warnings));
|
||||
assert!(w.contains("holds 60 rows") && w.contains("first 5"), "{layout}: {w}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! A table (`layout="table"`, its rows `layout="row"`) placed in positioned content repeats a
|
||||
//! row subform once per instance, as a table in flowed content does: the template's `occur`
|
||||
//! and the data's rows decide how many (XFA 3.3, "Tables", p. 327; "Repeating Elements using
|
||||
//! Occurrence Limits", p. 263; Chapter 9, p. 336), and the data rows past `occur max` or the
|
||||
//! layout's own limit are reported, not dropped silently.
|
||||
|
||||
use super::*;
|
||||
use crate::layout::Item;
|
||||
use crate::model::Rect;
|
||||
|
||||
/// Where the table sits.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Place {
|
||||
/// At its own `x`/`y` in a positioned subform.
|
||||
Positioned,
|
||||
/// In a top-to-bottom subform at a position in a positioned subform, with a field after it.
|
||||
PositionedFlow,
|
||||
/// In the flowed body, with a field after it.
|
||||
Flowed,
|
||||
}
|
||||
|
||||
/// A form with a table whose header row (`head`, shown once) and footer row (`foot`) frame a
|
||||
/// repeating `item` row with `occur` attributes `occur`, and data holding `rows` items (and two
|
||||
/// header rows, which the template shows once). Each row is 20pt tall.
|
||||
fn table_form(place: Place, occur: &str, rows: usize) -> String {
|
||||
let items: String = (1..=rows).map(|i| format!("<item><qty>row {i}</qty></item>")).collect();
|
||||
let table = r#"<subform name="table" layout="table" columnWidths="200pt" POS><border><edge/></border>
|
||||
<subform name="head" layout="row"><field name="label" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
<subform name="item" layout="row"><occur OCCUR/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
<subform name="foot" layout="row"><field name="total" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
</subform>"#
|
||||
.replace("OCCUR", occur);
|
||||
let after = r#"<field name="after" w="200pt" h="20pt"><ui><textEdit/></ui></field>"#;
|
||||
let (page, data) = match place {
|
||||
Place::Positioned => {
|
||||
(format!(r#"<subform name="page1" w="540pt" h="700pt">{}</subform>"#, table.replace("POS", r#"x="36pt" y="72pt""#)), "page1")
|
||||
}
|
||||
Place::PositionedFlow => (
|
||||
format!(
|
||||
r#"<subform name="page1" w="540pt" h="700pt"><subform name="wrap" layout="tb" w="200pt" x="36pt" y="72pt">{}{after}</subform></subform>"#,
|
||||
table.replace("POS", "")
|
||||
),
|
||||
"page1><wrap",
|
||||
),
|
||||
Place::Flowed => (format!(r#"<subform name="page1" layout="tb" w="540pt">{}{after}</subform>"#, table.replace("POS", "")), "page1"),
|
||||
};
|
||||
let close: String = data.split("><").collect::<Vec<_>>().iter().rev().map(|n| format!("</{n}>")).collect();
|
||||
format!(
|
||||
r#"<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="p"><occur max="-1"/><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
{page}
|
||||
</subform></template>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><{data}><table><head><label>H</label></head><head><label>H2</label></head>{items}<foot><total>T</total></foot></table>{close}</form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A laid-out widget: (field name, SOM, value, rect).
|
||||
type Laid = (String, String, Option<String>, Rect);
|
||||
|
||||
/// The laid-out widgets, in order.
|
||||
fn laid_out(form: &Form) -> Vec<Laid> {
|
||||
form.pages
|
||||
.iter()
|
||||
.flat_map(|p| p.items.iter())
|
||||
.filter_map(|it| if let Item::Widget(w) = it { Some((w.name.clone(), w.som.clone(), w.value.clone(), w.rect)) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn named<'a>(all: &'a [Laid], field: &str) -> Vec<&'a Laid> {
|
||||
all.iter().filter(|w| w.1.ends_with(&format!(".{field}[0]"))).collect()
|
||||
}
|
||||
|
||||
fn qty_values(all: &[Laid]) -> Vec<String> {
|
||||
named(all, "qty").iter().map(|w| w.2.clone().unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
fn expected(n: usize) -> Vec<String> {
|
||||
(1..=n).map(|i| format!("row {i}")).collect()
|
||||
}
|
||||
|
||||
fn cut_warning(form: &Form) -> Option<&String> {
|
||||
form.warnings.iter().find(|w| w.contains("rows; only the first"))
|
||||
}
|
||||
|
||||
/// Header, the rows and footer one under another, 20pt apart, from the header's top.
|
||||
fn assert_stacked(all: &[Laid], rows: usize, what: &str) {
|
||||
let head = named(all, "label");
|
||||
assert_eq!(head.len(), 1, "{what}: the header row is shown once: {head:?}");
|
||||
let top = head[0].3.y;
|
||||
let qty = named(all, "qty");
|
||||
assert_eq!(qty.len(), rows, "{what}");
|
||||
for (i, w) in qty.iter().enumerate() {
|
||||
assert!((w.3.y - (top + 20.0 * (i as f64 + 1.0))).abs() < 0.01, "{what}: row {i} at {:?}, header at {top}", w.3);
|
||||
}
|
||||
let foot = named(all, "total");
|
||||
assert_eq!(foot.len(), 1, "{what}: the footer row is shown once");
|
||||
assert!((foot[0].3.y - (top + 20.0 * (rows as f64 + 1.0))).abs() < 0.01, "{what}: footer at {:?} after {rows} rows", foot[0].3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_in_positioned_content_gets_a_row_per_data_row() {
|
||||
for place in [Place::Positioned, Place::PositionedFlow] {
|
||||
let form = layout_xml(&table_form(place, r#"min="1" max="-1""#, 5)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
assert_eq!(qty_values(&all), expected(5), "{place:?}");
|
||||
let soms: Vec<&str> = named(&all, "qty").iter().map(|w| w.1.as_str()).collect();
|
||||
let prefix = if matches!(place, Place::Positioned) { "form[0].page1[0].table[0]" } else { "form[0].page1[0].wrap[0].table[0]" };
|
||||
let want: Vec<String> = (0..5).map(|i| format!("{prefix}.item[{i}].qty[0]")).collect();
|
||||
assert_eq!(soms, want, "{place:?}: each row with its own SOM index");
|
||||
assert_stacked(&all, 5, &format!("{place:?}"));
|
||||
assert_eq!(named(&all, "label")[0].2.as_deref(), Some("H"), "{place:?}: the header takes the first header data row");
|
||||
assert!(cut_warning(&form).is_none(), "{place:?}: {:?}", form.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_in_positioned_content_is_measured_with_its_rows() {
|
||||
// What follows the table in a top-to-bottom container comes after the footer, not after one
|
||||
// row: the table is measured with every instance.
|
||||
let form = layout_xml(&table_form(Place::PositionedFlow, r#"min="1" max="-1""#, 5)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
let foot = named(&all, "total")[0].3;
|
||||
let after = named(&all, "after")[0].3;
|
||||
assert!((after.y - foot.bottom()).abs() < 0.01, "after {after:?}, footer {foot:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occur_max_bounds_a_positioned_table_and_the_rows_cut_are_reported() {
|
||||
for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
|
||||
let form = layout_xml(&table_form(place, r#"min="1" max="3""#, 5)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
assert_eq!(qty_values(&all), expected(3), "{place:?}");
|
||||
assert_stacked(&all, 3, &format!("{place:?}"));
|
||||
let warning = cut_warning(&form).unwrap_or_else(|| panic!("{place:?}: {:?}", form.warnings));
|
||||
assert!(warning.contains("item") && warning.contains("5 rows") && warning.contains("first 3"), "{place:?}: {warning}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_data_rows_a_positioned_table_takes_its_initial_count() {
|
||||
let form = layout_xml(&table_form(Place::Positioned, r#"min="1" max="-1" initial="2""#, 0)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
assert_stacked(&all, 2, "initial 2");
|
||||
assert!(cut_warning(&form).is_none(), "{:?}", form.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_positioned_table_lays_out_the_rows_a_flowed_one_does() {
|
||||
// One rule for both: the same rows, values, spacing and warnings, wherever the table sits.
|
||||
for (occur, rows) in [(r#"min="1" max="-1""#, 5), (r#"min="2" max="4""#, 7), (r#"min="1" max="-1""#, 0), (r#"min="3""#, 1)] {
|
||||
let flowed = layout_xml(&table_form(Place::Flowed, occur, rows)).unwrap();
|
||||
let positioned = layout_xml(&table_form(Place::Positioned, occur, rows)).unwrap();
|
||||
let (f, p) = (laid_out(&flowed), laid_out(&positioned));
|
||||
assert_eq!(qty_values(&p), qty_values(&f), "{occur} with {rows} rows");
|
||||
let offsets = |all: &[Laid]| -> Vec<f64> {
|
||||
let top = named(all, "label").first().map_or(0.0, |w| w.3.y);
|
||||
all.iter().filter(|w| w.0 != "after").map(|w| w.3.y - top).collect()
|
||||
};
|
||||
assert_eq!(offsets(&p), offsets(&f), "{occur} with {rows} rows");
|
||||
assert_eq!(cut_warning(&positioned).is_some(), cut_warning(&flowed).is_some(), "{occur} with {rows} rows");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_occur_values_and_data_floods_lay_a_positioned_table_out_within_bounds() {
|
||||
for occur in [
|
||||
r#"min="-3" max="-7" initial="99999999999999999999""#,
|
||||
r#"min="abc" max="1e9" initial="-1""#,
|
||||
r#"min="999999" max="999999" initial="999999""#,
|
||||
r#"max="0""#,
|
||||
r#"min="0" max="0" initial="0""#,
|
||||
] {
|
||||
for rows in [0, 4] {
|
||||
let form = layout_xml(&table_form(Place::Positioned, occur, rows)).unwrap();
|
||||
let flowed = layout_xml(&table_form(Place::Flowed, occur, rows)).unwrap();
|
||||
assert_eq!(qty_values(&laid_out(&form)).len(), qty_values(&laid_out(&flowed)).len(), "{occur} with {rows} rows");
|
||||
}
|
||||
}
|
||||
// More data rows than the layout lays out: bounded, and said.
|
||||
let rows = 1_200;
|
||||
let form = layout_xml(&table_form(Place::Positioned, r#"min="1" max="-1""#, rows)).unwrap();
|
||||
let n = qty_values(&laid_out(&form)).len();
|
||||
assert!((1..rows).contains(&n), "{n} of {rows} rows");
|
||||
let warning = cut_warning(&form).unwrap_or_else(|| panic!("{:?}", form.warnings));
|
||||
assert!(warning.contains(&format!("{rows} rows")) && warning.contains(&format!("first {n}")), "{warning}");
|
||||
}
|
||||
|
||||
/// A repeating positioned `entry` subform, each instance holding a positioned table whose `item`
|
||||
/// rows repeat, with a field after the entries; the data gives entry `i` `rows[i]` rows.
|
||||
fn nested_form(place: Place, rows: &[usize]) -> String {
|
||||
let entries: String = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(e, n)| {
|
||||
let items: String = (1..=*n).map(|i| format!("<item><qty>{e}.{i}</qty></item>")).collect();
|
||||
format!("<entry><table>{items}</table></entry>")
|
||||
})
|
||||
.collect();
|
||||
let entry = r#"<subform name="entry" w="200pt"><occur min="1" max="-1"/>
|
||||
<subform name="table" layout="table" columnWidths="200pt" x="0pt" y="0pt">
|
||||
<subform name="item" layout="row"><occur min="1" max="-1"/><field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field></subform>
|
||||
</subform></subform>"#;
|
||||
let after = r#"<field name="after" w="200pt" h="20pt"><ui><textEdit/></ui></field>"#;
|
||||
let (page, open, close) = match place {
|
||||
Place::PositionedFlow => (
|
||||
format!(
|
||||
r#"<subform name="page1" w="540pt" h="700pt"><subform name="wrap" layout="tb" w="200pt" x="36pt" y="72pt">{entry}{after}</subform></subform>"#
|
||||
),
|
||||
"<page1><wrap>",
|
||||
"</wrap></page1>",
|
||||
),
|
||||
_ => (format!(r#"<subform name="page1" layout="tb" w="540pt">{entry}{after}</subform>"#), "<page1>", "</page1>"),
|
||||
};
|
||||
format!(
|
||||
r#"<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="p"><occur max="-1"/><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
{page}
|
||||
</subform></template>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form>{open}{entries}{close}</form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_instance_of_a_repeating_container_is_measured_with_its_own_rows() {
|
||||
// Instances of one repeating subform hold different numbers of rows in the data (XFA 3.3,
|
||||
// "Tables", p. 327; Chapter 9, p. 336): each takes the height of its own rows, so the rows
|
||||
// of one never run into the next instance or what follows.
|
||||
for place in [Place::Flowed, Place::PositionedFlow] {
|
||||
for rows in [[1, 5], [5, 1], [2, 3]] {
|
||||
let what = format!("{place:?} {rows:?}");
|
||||
let form = layout_xml(&nested_form(place, &rows)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
let qty = named(&all, "qty");
|
||||
let want: Vec<String> = rows.iter().enumerate().flat_map(|(e, n)| (1..=*n).map(move |i| format!("{e}.{i}"))).collect();
|
||||
assert_eq!(qty.iter().map(|w| w.2.clone().unwrap_or_default()).collect::<Vec<_>>(), want, "{what}");
|
||||
let top = qty[0].3.y;
|
||||
for (i, w) in qty.iter().enumerate() {
|
||||
assert!((w.3.y - (top + 20.0 * i as f64)).abs() < 0.01, "{what}: row {i} at {:?}, first at {top}", w.3);
|
||||
}
|
||||
let after = named(&all, "after")[0].3;
|
||||
let last = qty[qty.len() - 1].3;
|
||||
assert!((after.y - last.bottom()).abs() < 0.01, "{what}: after {after:?}, last row {last:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_row_instance_takes_the_height_of_its_own_growing_text() {
|
||||
// A repeating row whose cell grows with its text (a multi-line field without a height) is
|
||||
// as tall as its own instance's value (XFA 3.3, "Tables", p. 327; "Growable Containers",
|
||||
// p. 271): a long value makes its row taller and pushes the rows after it down, wherever
|
||||
// the table sits.
|
||||
let long = "a long value that wraps over several lines of the cell ".repeat(6);
|
||||
for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
|
||||
let xml = table_form(place, r#"min="1" max="-1""#, 3)
|
||||
.replace(
|
||||
r#"<field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field>"#,
|
||||
r#"<field name="qty" w="200pt" minH="20pt"><ui><textEdit multiLine="1"/></ui></field>"#,
|
||||
)
|
||||
.replace("<qty>row 2</qty>", &format!("<qty>{long}</qty>"));
|
||||
let form = layout_xml(&xml).unwrap();
|
||||
let all = laid_out(&form);
|
||||
let qty = named(&all, "qty");
|
||||
assert_eq!(qty.len(), 3, "{place:?}");
|
||||
let (short, tall) = (qty[0].3, qty[1].3);
|
||||
assert!(tall.h > short.h + 20.0, "{place:?}: the long row {tall:?} is no taller than a short one {short:?}");
|
||||
for pair in qty.windows(2) {
|
||||
let (a, b) = (pair[0].3, pair[1].3);
|
||||
assert!((b.y - a.bottom()).abs() < 0.01, "{place:?}: row at {b:?} after {a:?}");
|
||||
}
|
||||
let foot = named(&all, "total")[0].3;
|
||||
assert!((foot.y - qty[2].3.bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", qty[2].3);
|
||||
if let Some(after) = named(&all, "after").first() {
|
||||
assert!((after.3.y - foot.bottom()).abs() < 0.01, "{place:?}: after {:?}, footer {foot:?}", after.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A multi-line `qty` text field 100pt wide that grows with its text, with `access` and
|
||||
/// template value `value`.
|
||||
fn qty_cell(access: &str, value: &str) -> String {
|
||||
format!(
|
||||
r#"<field name="qty" w="100pt" minH="20pt" access="{access}"><ui><textEdit multiLine="1"/></ui><value><text>{value}</text></value></field>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// [`table_form`] with two same-named cells per `item` row, `qty[0]` and `qty[1]` (`first` and
|
||||
/// `second`), in two 100pt columns, and the data of row `i` holding `data[i]`.
|
||||
fn same_name_cells_form(place: Place, first: &str, second: &str, data: [&str; 3]) -> String {
|
||||
let mut xml = table_form(place, r#"min="1" max="-1""#, 3)
|
||||
.replace(r#"columnWidths="200pt""#, r#"columnWidths="100pt 100pt""#)
|
||||
.replace(r#"<field name="qty" w="200pt" h="20pt"><ui><textEdit/></ui></field>"#, &format!("{first}{second}"));
|
||||
for (i, d) in data.iter().enumerate() {
|
||||
xml = xml.replace(&format!("<item><qty>row {}</qty></item>", i + 1), &format!("<item>{d}</item>"));
|
||||
}
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_is_as_tall_as_its_tallest_same_named_cell() {
|
||||
// Each cell of a row is measured under its own SOM index among its same-named siblings,
|
||||
// the index it is drawn and bound at (XFA 3.3, "Tables", p. 327; "Growable Containers",
|
||||
// p. 271; Chapter 9, p. 336): a short `qty[0]` beside a long `qty[1]` makes the row as tall
|
||||
// as the long one, and the rows and content after it start below it.
|
||||
let long = "a long value that wraps over several lines of the cell".repeat(6);
|
||||
let cell = qty_cell("open", "");
|
||||
for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
|
||||
let data = ["<qty>s1</qty><qty>t1</qty>", &format!("<qty>s2</qty><qty>{long}</qty>"), "<qty>s3</qty><qty>t3</qty>"];
|
||||
let form = layout_xml(&same_name_cells_form(place, &cell, &cell, data)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
let first = named(&all, "qty");
|
||||
let second: Vec<&Laid> = all.iter().filter(|w| w.1.ends_with(".qty[1]")).collect();
|
||||
assert_eq!(first.iter().map(|w| w.2.clone().unwrap_or_default()).collect::<Vec<_>>(), ["s1", "s2", "s3"], "{place:?}");
|
||||
assert_eq!(second.len(), 3, "{place:?}");
|
||||
assert_eq!(second[1].2.as_deref().map(str::trim), Some(long.trim()), "{place:?}");
|
||||
let tall = second[1].3;
|
||||
assert!(tall.h > 60.0, "{place:?}: the long cell {tall:?} did not grow");
|
||||
// Both cells of the row take its height, and the next row starts below it.
|
||||
assert!((first[1].3.h - tall.h).abs() < 0.01, "{place:?}: qty[0] {:?} beside qty[1] {tall:?}", first[1].3);
|
||||
for cells in [&first, &second] {
|
||||
for pair in cells.windows(2) {
|
||||
let (a, b) = (pair[0].3, pair[1].3);
|
||||
assert!((b.y - a.bottom()).abs() < 0.01, "{place:?}: row at {b:?} after {a:?}");
|
||||
}
|
||||
}
|
||||
let foot = named(&all, "total")[0].3;
|
||||
assert!((foot.y - second[2].3.bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", second[2].3);
|
||||
if let Some(after) = named(&all, "after").first() {
|
||||
assert!((after.3.y - foot.bottom()).abs() < 0.01, "{place:?}: after {:?}, footer {foot:?}", after.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_non_interactive_same_named_cell_stays_in_its_row() {
|
||||
// A non-interactive `qty[1]` is drawn as text, not as a widget; measured under its own SOM
|
||||
// index (where the data holds nothing, so its long template value is what shows), each row
|
||||
// holds its lines, and none run into the row after it or the footer.
|
||||
let long = "longword ".repeat(12);
|
||||
for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
|
||||
let data = ["<qty>s1</qty>", "<qty>s2</qty>", "<qty>s3</qty>"];
|
||||
let form = layout_xml(&same_name_cells_form(place, &qty_cell("open", ""), &qty_cell("nonInteractive", &long), data)).unwrap();
|
||||
let all = laid_out(&form);
|
||||
let rows: Vec<Rect> = named(&all, "qty").iter().map(|w| w.3).collect();
|
||||
assert_eq!(rows.len(), 3, "{place:?}");
|
||||
let lines: Vec<f64> = form
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| p.items.iter())
|
||||
.filter_map(|it| if let Item::Text(s) = it { Some(s) } else { None })
|
||||
.filter(|s| s.text.contains("longword"))
|
||||
.map(|s| s.baseline)
|
||||
.collect();
|
||||
assert!(lines.len() >= 6, "{place:?}: {lines:?}");
|
||||
let per_row: Vec<usize> = rows.iter().map(|r| lines.iter().filter(|&&b| b > r.y && b <= r.bottom()).count()).collect();
|
||||
assert_eq!(per_row.iter().sum::<usize>(), lines.len(), "{place:?}: lines {lines:?} outside rows {rows:?}");
|
||||
assert!(per_row.iter().all(|&n| n == per_row[0] && n > 1), "{place:?}: lines per row {per_row:?}");
|
||||
let foot = named(&all, "total")[0].3;
|
||||
assert!((foot.y - rows[2].bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", rows[2]);
|
||||
}
|
||||
}
|
||||
@@ -4268,7 +4268,8 @@ fn imported_data_is_written_with_the_packets_own_prefix_and_never_leaves_it_unre
|
||||
assert!(import_data(&mut doc, &deepish, Some("form"), None).is_err());
|
||||
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"), "untouched");
|
||||
// The data holds more rows than are laid out: said, not hidden.
|
||||
let rows: String = (0..60).map(|i| format!("<row><item>r{i}</item><amount>1</amount></row>")).collect();
|
||||
let n = crate::layout::MAX_INSTANCES + 10;
|
||||
let rows: String = (0..n).map(|i| format!("<row><item>r{i}</item><amount>1</amount></row>")).collect();
|
||||
let many = scripted_template().replace(
|
||||
"</template>",
|
||||
&format!(
|
||||
@@ -4276,7 +4277,7 @@ fn imported_data_is_written_with_the_packets_own_prefix_and_never_leaves_it_unre
|
||||
),
|
||||
);
|
||||
let form = layout_xml(&many).unwrap();
|
||||
assert!(form.warnings.iter().any(|w| w.contains("60 rows") && w.contains("first 50")), "{:?}", form.warnings);
|
||||
assert!(form.warnings.iter().any(|w| w.contains(&format!("{n} rows")) && w.contains(&format!("first {}", n - 10))), "{:?}", form.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5471,8 +5471,8 @@ milestone = "M12"
|
||||
status = "partial"
|
||||
commands = ["file.open", "file.save", "form.export_data", "form.import_data"]
|
||||
tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"]
|
||||
evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::a_two_digit_year_splits_its_century_at_30", "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", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools"]
|
||||
notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (`<bind><picture>`, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2030 in a two-digit year, which holds 1930 to 2029 as XFA pictures split the century at 30), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, 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. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)"
|
||||
evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::a_two_digit_year_splits_its_century_at_30", "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", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/it/automation.rs::xfa_data_files_export_and_import_through_tools", "crates/xfa/src/repeat_tests.rs::every_data_row_past_fifty_is_laid_out_and_paginated", "crates/xfa/src/repeat_tests.rs::occur_max_still_stops_the_rows_and_says_so", "crates/xfa/src/repeat_tests.rs::rows_past_the_instance_limit_are_cut_with_a_warning", "crates/xfa/src/repeat_tests.rs::an_initial_count_lays_out_whole_up_to_the_limit", "crates/engine/tests/it/xfa_repeat.rs::a_button_adding_rows_past_fifty_lays_them_all_out", "crates/xfa/src/table_repeat_tests.rs::a_table_in_positioned_content_gets_a_row_per_data_row", "crates/xfa/src/table_repeat_tests.rs::occur_max_bounds_a_positioned_table_and_the_rows_cut_are_reported", "crates/xfa/src/table_repeat_tests.rs::a_positioned_table_lays_out_the_rows_a_flowed_one_does", "crates/engine/tests/it/xfa_repeat.rs::a_table_in_a_positioned_subform_gets_a_row_per_data_row_up_to_its_max", "crates/xfa/src/table_repeat_tests.rs::each_instance_of_a_repeating_container_is_measured_with_its_own_rows"]
|
||||
notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (`<bind><picture>`, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2030 in a two-digit year, which holds 1930 to 2029 as XFA pictures split the century at 30), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, 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. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed: a repeating subform or table row, in a flowed or a positioned container, lays out every row up to its occur max, or up to 1,000 when it has none, and the rows past that are reported; each instance of a repeating container is as tall as its own rows) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-flatten"
|
||||
|
||||
Reference in new issue
Block a user