M6: Prepare a form ▸ Text field table: rows and columns of text fields as one edit (#3479)

* M6: Prepare a form ▸ Text field table: rows and columns of text fields as one edit

Drag an area with the Text field table tool and set it up in a movable
window: rows, columns, row and column spacing, the first row number,
column widths (draggable dividers, arrow keys once focused) and a name
pattern per column, with the table previewed on the page. Add fields adds
every cell as one undoable edit ("Add text field table").

- forms: `table` lays the table out in view space and names its cells. It
  refuses the whole table for a cell under MIN_FIELD_SIDE (now one
  constant) or a name with a period, used twice or already taken, and
  free_base picks the first free TableK for default names.
- engine: `field_table::edit` maps the cells onto the page, /Rotate and
  /UserUnit included, and checks their size again there.
- automation: `form_add_field_table`, with an end-to-end test.
- ui-egui: the window belongs to one page of one document and closes when
  that stops being the one prepared; Enter commits a number instead of
  adding the table; dividers between many columns no longer panic in
  f64::clamp. Strings in all 22 catalogs, and the Spanish engine-wording
  tests cover the table's mistakes.
- parity: form.multiple-copies is partial; copying an existing field into
  a grid is still missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M6: text field table: refuse corners past the page's number range, run it by its command

Review follow-up on #3479.

- engine: a corner finite as an f64 but past f32's range came back from
  the page's f32 mapping infinite. With one such corner a side was
  infinite rather than NaN, so the cell passed and add_field refused it
  only once the edit was applied ("the field is too small"). The edit now
  refuses any corner that isn't finite on the page with the table's own
  reason, before anything is added.
- automation: form.add.text_table runs form_add_field_table through
  command_run, as comment.stamp runs stamp_custom.
- ui-egui: doc comments for the window's three remaining helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M6: text field table: close on document edits, lay out once per change, keep Escape for fields

Review follow-up on #3479 (benandez).

- The window belongs to the document as it was when it opened: it closes
  when the edit generation moves (a page rotated, moved or deleted, an edit
  undone), so a table set up for one page never lands on another or off it.
- The table is laid out and checked once per change, not several times a
  frame: the window and the page preview share one `Laid` (cells and the
  edit), and the preview draws only the cells in view, naming only those
  with room for a name.
- Escape in a field of the window leaves the field and keeps the setup; a
  second Escape closes the window.
- A table placed with a click is moved in from the page's edges, so all of
  it is on the page.
- `free_base` takes one pass over the names; `{seq}` is documented as
  counting from the first row number; `form_add_field_table` takes its
  names from the batch instead of laying the table out again; the
  unreachable `Dialog::FieldTable` arm says why it is there.

Each new test fails with its fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z

* M6: text field table: drawn with its panel closed, the window opens Prepare a form instead of closing

With the tool picked and the Prepare a form panel closed, the form is
still prepared and a table can be drawn. Drawing sends the tool back to
Select, though, so nothing was prepared on the next frame, and the table
window, which lasts only while the form is prepared, closed itself at
once: the table couldn't be set up. Opening the window now opens the
Prepare a form panel it belongs to. Closing the panel while the window
is open still closes the window.

Found by CodeRabbit, which saw the preview stop drawing; the window
closing first was the larger effect. prepare.rs
a_table_drawn_with_the_panel_closed_opens_it_again fails without the fix
("the window stays open": (None, false)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z

* Drop the second generation binding the main merge left in document_area

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
This commit is contained in:
Piohrun
2026-10-22 03:27:31 +02:00
committed by GitHub
parent ffaf312358
commit d676fd44ea
45 changed files with 2274 additions and 45 deletions
+22 -22
View File
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "53b57cc89461ecc4d48350a4b2ef969c9ba138048a8e28d6a8ee45d549847e1f"
sha256 = "ba26246db8ac2a7262fa720b6c5da9e0ae1b3f5198dc29aa260cf274d332ba27"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "1d4b5c40d08e0c3ee328ffee487ff4133627381cf865d21bec682c247e6628dc"
sha256 = "a1182f567b2ce1626d3ea84ad6c91eb26e1de5d0eb0628f68f9f200356bf6435"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "11045efab47ed4a962861f0e3e9568836a8e9ece9ea2e0a4ec58ccf78d2e677f"
sha256 = "f309799de30c6652e49cbfd667cb2387964caa56344dd06d645bb6e6c2a552ca"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cb4c53d7b938a5ec8eb3a001a6c385c60b84f9f1ac36240c490d164046757c2a"
sha256 = "33db8b928d3729f9e5f1730efe51124141055a63979903a9b9116434045f8407"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "af4b798c568cb1bb44ef23700e2d69c1e91c7cbb81e6fb565079a797cf02c878"
sha256 = "0fce5a7bdb47afd40de8843dae28370e4992fe57d9944d0111957ff13b2ed3f8"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "8fe572236493148b23270ef5766ad1c7d65a58b293757f34772d01a44a2df026"
sha256 = "54cb7447058bb3d5865d1a05eddcf4fe78b22fdce0daea7aba45a0fe51134436"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6af18efaa6fc8d7d9e813b4411380f1f47879c3e342d800540abec5cd6cb8f81"
sha256 = "df75aa84138a0b7d4eeaae4a24e2f8e85079ce1887efc01cb3107304ff6478dc"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2b7787232635373d44b16b93051763b01cf8fa9e6877c87c013ad42171fdc915"
sha256 = "b34a7c2e9d02d357cb214a7c4d5d79b599576b9e5174d5069e6dae81eb6d340b"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "9f67da0f5b455cf99be298e8318721ad6c8d306223e74ad42faf6edd2f8a839b"
sha256 = "b8d743ccbd2f628beed04690b810038b62f2da0fedde63e8335b3d171491d3f0"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f7765338632fee507a907cbabc52f72d57d903839f8a4d48b34e2cdcbb28a3bc"
sha256 = "ad2f8f099470ae2a028ff51ff6a073691b4f15e0d933f11d1f38712128b45acd"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "62517afff28af384a42aa377b86582ecddfe8dc8453f06c481246e96184add59"
sha256 = "27b98fe44cf5c19279dec4b948ebf86e2a87ef4601ca4013cf2d5a2c7ec6d77a"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d46ff4a0505c6374008d55fc4112d4a41508c333411551dad8f6102243bbcccd"
sha256 = "2e4af3ed74f41f9d47fcce5280f063b9dde86173f56d510aded3d98ec73ca4dd"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "61f9efbc72c1e597cdb9a2892bb87e4d0b64c1a7736bc8f7d719e9f18c85da12"
sha256 = "46d2f5913acce24178ab186e7b9ddf3ed60e5f4a738920d51fa9bf1137cb4dcf"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "90fac37b3f4c69d16841fec2bc30b66aa3cd0964a493ebdac4f71e40dc424164"
sha256 = "81c2bd98a5fb913c809dc61cfd1f7c65421c605808cb4d7cfe918447211da0b9"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "18c15dd98f6efe9929d6b00360dc6d15a44bbe6bfde044688bc911011868efeb"
sha256 = "5216653fbce9f75255b31ef6c12b75c3c0fbfedfeec6806eef1afa22b3229a70"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "07b222d5b3fdd155ba57cec323dc9ca290c6e01331bb32f7181f21728362d53b"
sha256 = "02e29bbefbd593c44bf1efa12282d7462b67f7740e03b70dbad2c796d99def89"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "b15b5fdca526de6d39d4a22b0c4d54f13eb5df0691ef6c0ea5e4987122ea45be"
sha256 = "1c6672a67f7e7fbbf015c4604f5706e5f0736145a24488b3af4fe2c7231073b6"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "882c07204a95bef324f67f891e76dae0a1a499a420e07929490e6b7f62cf4487"
sha256 = "6a25bd21de4ce538be59aec5456d43a2e33d20e4e63ae7b933b1c6ceb4349224"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7afdd36b96ac5b9c4c1023f506b98af9c559fe85ea00eb7e9518eda9fe77850b"
sha256 = "7474bd8d39966455d5275ceda701139ac392e02391ac9bc6c30f48336041bd80"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
@@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6ed886d00ad8cd39d8bf254f4733c8b8c90d5bf2c6868ff9c4ca42765a148786"
sha256 = "0bb73d88f61e5552008c4d807d3b8b2f1547281d1f68b26458029407eacefcf6"
[[asset]]
@@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2c352743eaeec21eb9e522c71c43f3f6df34b81f024a5c72c85332e2ecc2b338"
sha256 = "53edd35cad3ccf835f7a2de3235c16a77da1a5cde1ac4a18d275de9031717391"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a212a56fd316e58349845c750e935b28ba5074b3fd096016c3ae92cbe502648f"
sha256 = "0452511f97f17bfa97e63de585bc5b973e1aceec98dd0c3cc5a256414f585a03"
[[asset]]
+1
View File
@@ -175,6 +175,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
- **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged.
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
+62 -1
View File
@@ -1,5 +1,5 @@
//! Form tools: list fields, fill them (several at once, one undo step), clear the form, and
//! prepare a form (add, change and delete fields).
//! prepare a form (add fields one at a time or as a table, change and delete them).
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate};
use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
@@ -339,6 +339,67 @@ impl Automation {
Ok(out)
}
/// A table of text fields: `rect` (points from the top-left of the displayed page) divided
/// into rows and columns, one field per cell, as one undo step.
pub(crate) fn form_add_field_table(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::field_table::{self, FieldTable, MAX_COLUMNS, TableColumn, TableError};
let page = self.page(a)?;
let rect = a
.get("rect")
.and_then(Value::as_array)
.and_then(|r| r.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>())
.and_then(|r| <[f64; 4]>::try_from(r).ok())
.ok_or_else(|| bad("rect must be 4 numbers"))?;
let names = a.get("names").map(|_| a.strs("names")).transpose()?;
let widths = match a.get("widths") {
None => None,
Some(v) => Some(
v.as_array()
.and_then(|w| w.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>())
.ok_or_else(|| bad("widths must be an array of numbers"))?,
),
};
// A negative count is no count: the table refuses it with its own words.
let counts = [a.opt_int("columns")?.map(|n| usize::try_from(n).unwrap_or(0)), names.as_ref().map(Vec::len), widths.as_ref().map(Vec::len)];
let mut given = counts.into_iter().flatten();
let columns = given.next().unwrap_or(1);
if given.any(|n| n != columns) {
return Err(bad("columns, names and widths must agree on the number of columns"));
}
// Before anything is sized by it.
if !(1..=MAX_COLUMNS).contains(&columns) {
return Err(bad(TableError::Columns.to_string()));
}
let (edit, fields) = {
let doc = self.doc(a)?;
let base = field_table::free_base(doc.form.iter().map(|f| f.name.as_str()));
let mut table = FieldTable::new(rect, &base);
table.rows = usize::try_from(a.int("rows")?).unwrap_or(0);
table.columns = (0..columns)
.map(|c| TableColumn {
pattern: names.as_ref().and_then(|n| n.get(c)).map_or_else(|| field_table::column_pattern(&base, c + 1), |p| (*p).to_owned()),
width: widths.as_ref().and_then(|w| w.get(c)).copied().unwrap_or(1.0),
})
.collect();
table.row_gap = a.opt_num("row_gap")?.unwrap_or(0.0);
table.column_gap = a.opt_num("column_gap")?.unwrap_or(0.0);
table.first_row = a.opt_int("first_row")?.map_or(1, |n| usize::try_from(n).unwrap_or(usize::MAX));
let info = doc.info.pages.get(page).ok_or_else(|| bad(format!("page {} does not exist", page + 1)))?;
let edit = field_table::edit(&table, page, info, &doc.form).map_err(|e| bad(e.to_string()))?;
// The names the batch adds, in the order it adds them.
let fields: Vec<String> = match &edit {
Edit::Batch { edits, .. } => {
edits.iter().filter_map(|e| if let Edit::AddField { name, .. } = e { name.clone() } else { None }).collect()
}
_ => Vec::new(),
};
(edit, fields)
};
let mut out = self.apply(a, edit)?;
out["fields"] = json!(fields);
Ok(out)
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
if a.get("widget").is_some() && a.get("caption").is_none() {
return Err(bad("widget requires caption"));
+1
View File
@@ -726,6 +726,7 @@ impl Automation {
}
"form_reset" => self.form_reset(&a)?,
"form_add_field" => self.form_add_field(&a)?,
"form_add_field_table" => self.form_add_field_table(&a)?,
"form_set_props" => self.form_set_props(&a)?,
"form_delete_field" => self.form_delete_field(&a)?,
"form_tab_order" => self.form_tab_order(&a)?,
+21
View File
@@ -355,6 +355,27 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "page", "type", "rect"],
)),
t(
"form_add_field_table",
"Prepare form: add a table of text fields",
"Divide rect into rows and columns of text fields, one per cell, as one undo step. rect in points from the top-left of the displayed page [x0, y0, x1, y1]; rows 1-100 from the top; columns 1-50, or as many as names or widths list. Each column names its cells from its pattern in names: an N on its own after _ - / or : (price_N), # or {row} is the row number, {col} the column number, {seq} counts every cell along the rows, starting at first_row; with none of them the row number goes at the end. Rows count from first_row (default 1). Without names the columns are Table1_N, Table1_c2_N, … using the first free TableK. widths are relative (default equal); row_gap and column_gap are points between cells (default 0). Nothing is added if a cell would be under 4 points or a name would contain a period, repeat, or already exist; the error says which. Returns the fields' names. Undoable.",
)
.cmd("form.add.text_table")
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"rows": { "type": "integer", "minimum": 1, "maximum": 100 },
"columns": { "type": "integer", "minimum": 1, "maximum": 50 },
"names": { "type": "array", "items": { "type": "string" }, "description": "One name pattern per column." },
"widths": { "type": "array", "items": { "type": "number", "exclusiveMinimum": 0 }, "description": "Relative column widths, one per column." },
"row_gap": { "type": "number", "minimum": 0 },
"column_gap": { "type": "number", "minimum": 0 },
"first_row": { "type": "integer", "minimum": 0, "maximum": 99999 },
}),
&["doc", "page", "rect", "rows"],
)),
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1) or an image file, centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt. name labels it (default: the file name). Undoable.")
.cmd("comment.stamp")
.with(schema(
+59
View File
@@ -3391,6 +3391,65 @@ fn rotating_a_field_through_tools() {
assert_eq!((rot, back), (0, before));
}
/// A table of text fields through the tool: laid out from the top of the page as displayed, named
/// column by column, one undo step, and refused whole, with the reason, when any of it can't go in.
#[test]
fn adding_a_field_table_through_tools() {
let dir = workdir("field-table");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let table =
json!({ "doc": doc, "page": 1, "rect": [20, 20, 220, 120], "rows": 2, "names": ["desc_N", "price_N"], "widths": [3, 1], "row_gap": 10 });
assert_eq!(ok(&mut a, "form_add_field_table", table.clone())["fields"], json!(["desc_1", "price_1", "desc_2", "price_2"]));
let rects = |a: &mut Automation| {
let fields = ok(a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().clone();
fields.iter().map(|f| (f["name"].as_str().unwrap().to_owned(), f["rect"].clone())).collect::<Vec<_>>()
};
// 150 and 50 points wide, (100 - 10) / 2 = 45 high, the first row at the top.
let near = |v: &Value, want: [f64; 4]| v.as_array().unwrap().iter().zip(want).all(|(g, w)| (g.as_f64().unwrap() - w).abs() < 0.01);
let got = rects(&mut a);
for (name, want) in [
("desc_1", [20.0, 20.0, 170.0, 65.0]),
("price_1", [170.0, 20.0, 220.0, 65.0]),
("desc_2", [20.0, 75.0, 170.0, 120.0]),
("price_2", [170.0, 75.0, 220.0, 120.0]),
] {
let rect = got.iter().find(|(n, _)| n == name).map(|(_, r)| r).unwrap();
assert!(near(rect, want), "{name}: {rect} != {want:?}");
}
// Without names, each table takes the first free base.
let plain = json!({ "doc": doc, "page": 2, "rect": [20, 20, 220, 120], "rows": 2 });
assert_eq!(ok(&mut a, "form_add_field_table", plain.clone())["fields"], json!(["Table1_1", "Table1_2"]));
assert_eq!(ok(&mut a, "form_add_field_table", plain)["fields"], json!(["Table2_1", "Table2_2"]));
let count = |a: &mut Automation| ok(a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len();
assert_eq!(count(&mut a), 8);
// Refused whole, saying why: nothing is added.
let refused = |a: &mut Automation, args: Value| match a.call("form_add_field_table", &args) {
Err(ToolError::InvalidArgs(m)) => m,
other => panic!("refused, not {other:?}"),
};
assert_eq!(refused(&mut a, table), "a field named \"desc_1\" already exists");
let small = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 30 });
assert!(refused(&mut a, small).starts_with("the rows are too short"));
let dotted = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "names": ["a.{n}"] });
assert_eq!(refused(&mut a, dotted), "field names can't contain a period: \"a.1\"");
let disagree = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "columns": 3, "names": ["a_N", "b_N"] });
assert!(refused(&mut a, disagree).contains("must agree"));
let wide = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "columns": 51 });
assert_eq!(refused(&mut a, wide), "a table has 1 to 50 columns");
let none = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": -1 });
assert_eq!(refused(&mut a, none), "a table has 1 to 100 rows");
let huge = json!({ "doc": doc, "page": 1, "rect": [20, 200, 1e39, 300], "rows": 2 });
assert!(refused(&mut a, huge).starts_with("the area, the spacing and the column widths must be numbers"));
assert_eq!(count(&mut a), 8);
// The table tool's command runs it too.
let by_command = json!({ "id": "form.add.text_table", "params": { "doc": doc, "page": 3, "rect": [20, 20, 220, 120], "rows": 2 } });
assert_eq!(ok(&mut a, "command_run", by_command)["fields"], json!(["Table3_1", "Table3_2"]));
assert_eq!(count(&mut a), 10);
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add text field table");
assert_eq!(count(&mut a), 8, "one undo takes back the whole last table");
}
#[test]
fn redacting_through_tools() {
let dir = workdir("redact");
+1
View File
@@ -357,6 +357,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
title: "Add form components",
items: &[
item("Text field", "text-cursor-input", "form.add.text", Ready),
item("Text field table", "grid-3x3", "form.add.text_table", Ready),
item("Checkbox", "check-circle-2", "form.add.checkbox", Ready),
item("Radio button", "circle", "form.add.radio", Ready),
item("Drop-down list", "chevron-down", "form.add.combo", Ready),
+1
View File
@@ -293,6 +293,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("redact.sanitize", "Sanitize document…", None, None, Modification, "sparkles"),
c("form.field.properties", "Field properties…", None, None, HasFields, "settings-2"),
c("form.add.text", "Add a text field", None, None, Modification, "text-cursor-input"),
c("form.add.text_table", "Add text field table", None, None, Modification, "grid-3x3"),
c("form.add.checkbox", "Add a checkbox", None, None, Modification, "check-circle-2"),
c("form.add.radio", "Add a radio button", None, None, Modification, "circle"),
c("form.add.combo", "Add a drop-down list", None, None, Modification, "chevron-down"),
+131
View File
@@ -0,0 +1,131 @@
//! Prepare a form ▸ Text field table: one undoable edit adding a [`FieldTable`]'s cells as text
//! fields. The table is laid out and named in `pdfcraft_forms::table`; this maps its cells from
//! view space onto the page and refuses, before anything is added, what `add_field` would refuse.
pub use pdfcraft_forms::table::*;
use pdfcraft_forms::MIN_FIELD_SIDE;
use pdfcraft_render::PageInfo;
use crate::{Edit, FormField, NewField};
/// The edit's name in the history ("Undo Add text field table"), one of [`crate::BATCH_LABELS`].
pub const LABEL: &str = "Add text field table";
/// The edit adding `table`'s cells to `page` (0-based, shown as `info`) of a document whose
/// fields are `form`. Refused, with nothing added, when a cell would be too small on the page or
/// its name can't be used.
pub fn edit(table: &FieldTable, page: usize, info: &PageInfo, form: &[FormField]) -> Result<Edit, TableError> {
let cells = table.cells_for(form.iter().map(|f| f.name.as_str()))?;
// A page turned a quarter runs down the screen along user space's x.
let sideways = matches!(info.rotation, 90 | 270);
let mut edits = Vec::with_capacity(cells.len());
for cell in cells {
let [x0, y0, x1, y1] = cell.rect;
let rect = info.view_rect_to_user([x0 as f32, y0 as f32, x1 as f32, y1 as f32]).map(f64::from);
// A corner past `f32`'s range comes back infinite.
if !rect.iter().all(|v| v.is_finite()) {
return Err(TableError::Numbers);
}
// `add_field`'s own test, after the page's scale (`/UserUnit`) and rounding to `f32`.
let (across, down) = if sideways { (rect[3] - rect[1], rect[2] - rect[0]) } else { (rect[2] - rect[0], rect[3] - rect[1]) };
if down < MIN_FIELD_SIDE {
return Err(TableError::RowsTooShort);
}
if across < MIN_FIELD_SIDE {
return Err(TableError::ColumnTooNarrow);
}
edits.push(Edit::AddField { page, rect, kind: NewField::Text { multiline: false }, name: Some(cell.name) });
}
Ok(Edit::Batch { label: LABEL.to_owned(), edits })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
fn page(width: f32, height: f32, crop: [f32; 4], rotation: u16) -> PageInfo {
PageInfo { width, height, label: "1".into(), crop, rotation }
}
fn grid(rect: [f64; 4], rows: usize, patterns: &[&str]) -> FieldTable {
let mut t = FieldTable::new(rect, "unused");
t.rows = rows;
t.columns = patterns.iter().map(|p| TableColumn { pattern: (*p).to_owned(), width: 1.0 }).collect();
t
}
#[test]
fn a_table_is_added_and_undone_as_one_step() {
let mut s = Session::new();
let bytes = s.create_blank(300.0, 400.0, 1).unwrap();
let id = s.open("t.pdf", None, bytes, None).unwrap();
// 200 × 100 points from (50, 40) down the page as displayed: two rows of two.
let table = grid([50.0, 40.0, 250.0, 140.0], 2, &["desc_N", "price_N"]);
let add = |s: &Session| {
let doc = s.get(id).unwrap();
edit(&table, 0, &doc.info.pages[0], &doc.form)
};
s.apply(id, add(&s).unwrap()).unwrap();
let doc = s.get(id).unwrap();
let rect = |name: &str| doc.form.iter().find(|f| f.name == name).map(|f| f.widgets[0].rect);
// User space has y up: the first row is the higher one.
assert_eq!(rect("desc_1"), Some([50.0, 310.0, 150.0, 360.0]));
assert_eq!(rect("price_1"), Some([150.0, 310.0, 250.0, 360.0]));
assert_eq!(rect("desc_2"), Some([50.0, 260.0, 150.0, 310.0]));
assert_eq!(rect("price_2"), Some([150.0, 260.0, 250.0, 310.0]));
// Laid out again over the same names, it is refused before anything is added.
assert_eq!(add(&s).err(), Some(TableError::Taken("desc_1".into())));
assert_eq!(s.undo(id).unwrap().to_string(), LABEL);
assert!(s.get(id).unwrap().form.is_empty(), "one undo takes the whole table back");
}
#[test]
fn rows_run_down_a_turned_page_as_it_is_shown() {
let mut s = Session::new();
let bytes = s.create_blank(300.0, 400.0, 1).unwrap();
let id = s.open("t.pdf", None, bytes, None).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let doc = s.get(id).unwrap();
let info = doc.info.pages[0].clone();
assert_eq!((info.rotation, info.width, info.height), (90, 400.0, 300.0));
let table = grid([40.0, 30.0, 240.0, 130.0], 2, &["a_N", "b_N"]);
s.apply(id, edit(&table, 0, &info, &doc.form).unwrap()).unwrap();
// Each field, taken back to the page as shown, is where its cell was laid out.
let doc = s.get(id).unwrap();
for cell in table.cells().unwrap() {
let r = doc.form.iter().find(|f| f.name == cell.name).unwrap().widgets[0].rect;
let (a, b) = (info.user_to_view(r[0] as f32, r[1] as f32), info.user_to_view(r[2] as f32, r[3] as f32));
let shown = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(f64::from);
assert!(shown.iter().zip(cell.rect).all(|(s, c)| (s - c).abs() < 1e-3), "{}: {shown:?} vs {:?}", cell.name, cell.rect);
}
}
#[test]
fn a_cell_too_small_on_the_page_is_refused_along_the_right_side() {
// Half a point of user space for each point shown across, a whole one down: a quarter
// turn puts "across" on user space's y.
let info = page(200.0, 100.0, [0.0, 0.0, 100.0, 100.0], 90);
let narrow = grid([0.0, 0.0, 12.0, 12.0], 2, &["a_N", "b_N"]);
assert!(narrow.cells().is_ok(), "6 × 6 shown");
assert_eq!(edit(&narrow, 0, &info, &[]).err(), Some(TableError::ColumnTooNarrow));
let unturned = page(100.0, 200.0, [0.0, 0.0, 100.0, 100.0], 0);
assert_eq!(edit(&narrow, 0, &unturned, &[]).err(), Some(TableError::RowsTooShort));
let roomy = page(100.0, 100.0, [0.0, 0.0, 100.0, 100.0], 90);
assert!(edit(&narrow, 0, &roomy, &[]).is_ok());
}
/// A corner finite as an `f64` but past `f32`'s range lays out, then comes back from the page's
/// `f32` mapping infinite: one such corner leaves a side infinite, not NaN, and the cell used to
/// pass, for `add_field` to refuse it once the edit was applied.
#[test]
fn a_table_past_the_pages_number_range_is_refused_before_anything_is_added() {
let huge = grid([0.0, 0.0, 1e39, 100.0], 2, &["a_N"]);
assert!(huge.cells().is_ok(), "finite as laid out");
for rotation in [0, 90] {
let info = page(300.0, 400.0, [0.0, 0.0, 300.0, 400.0], rotation);
assert_eq!(edit(&huge, 0, &info, &[]).err(), Some(TableError::Numbers), "/Rotate {rotation}");
}
}
}
+3 -1
View File
@@ -24,6 +24,7 @@ pub mod compare;
mod copy_image;
pub mod dates;
pub mod export;
pub mod field_table;
pub mod js;
pub mod links;
pub mod ocr;
@@ -1952,7 +1953,8 @@ pub const EDIT_LABELS: &[&str] = &[
/// The names of the batches built below L7, which an interface shows in the same places as
/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives
/// already worded in the interface's language.
pub const BATCH_LABELS: &[&str] = &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text"];
pub const BATCH_LABELS: &[&str] =
&["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL];
impl Edit {
/// Label for the Edit menu and history ("Undo Rotate pages").
+8
View File
@@ -25,6 +25,14 @@ set_props(&mut doc, "email", &FieldProps {
..Default::default()
})?;
delete_field(&mut doc, "email")?;
// A table of text fields (view space, y down), laid out and named before any of it is added:
// refused whole for a cell under MIN_FIELD_SIDE or a name with a period, repeated or taken.
let names = all.iter().map(|f| f.name.as_str());
let base = table::free_base(names.clone()); // "Table1", or the next one free
let mut grid = table::FieldTable::new([40.0, 240.0, 240.0, 360.0], &base);
grid.set_columns(2, &base);
let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table1_c2_1", …
```
## Behaviour
+6 -3
View File
@@ -11,6 +11,9 @@ use pdfcraft_fonts::{helvetica_width, literal, win_ansi};
use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs};
/// The smallest width and height of a field's widget, in points: a smaller one is refused.
pub const MIN_FIELD_SIDE: f64 = 4.0;
/// The kind of field to add.
#[derive(Clone, Debug, PartialEq)]
pub enum NewField {
@@ -515,7 +518,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
let pages = page_refs(doc);
let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?;
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 4.0 || rect[3] - rect[1] < 4.0 {
if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < MIN_FIELD_SIDE || rect[3] - rect[1] < MIN_FIELD_SIDE {
return invalid("the field is too small");
}
let existing = fields(doc);
@@ -856,7 +859,7 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let (nw, nh) = (y1 - y0, x1 - x0);
let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0];
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out)
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out)
}
/// Change a field's properties (General and Options tabs) and redraw it.
@@ -1097,7 +1100,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if let Some((wi, r)) = props.rect {
let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?;
let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])];
if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < 4.0 || r[3] - r[1] < 4.0 {
if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE {
return invalid("the field is too small");
}
doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?;
+4 -2
View File
@@ -7,6 +7,7 @@
//! ([`appearance`]); check boxes and radio buttons switch `/V` and each widget's `/AS` between
//! the states their appearances already define.
//! - [`reset`]: Acrobat's Clear form (back to `/DV`).
//! - [`table`]: a table of text fields, laid out and named before any of it is added.
//!
//! Not yet: JavaScript actions (format, keystroke, validate, calculate — M6.4/M6.5) and rich text
//! values (`/RV`, which is removed when a value is set so it can't contradict `/V`).
@@ -22,10 +23,11 @@ mod author;
mod calc_order;
pub mod detect;
mod scripting;
pub mod table;
pub use actions::{FieldAction, Trigger, field_actions, set_field_actions};
pub use author::{
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, NewField, add_field, check_style, delete_field, duplicate_field, look,
redraw_field, set_button_icon, set_props,
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, duplicate_field,
look, redraw_field, set_button_icon, set_props,
};
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
pub use scripting::{
+663
View File
@@ -0,0 +1,663 @@
//! A table of text fields (Prepare a form ▸ Text field table): an area divided into rows and
//! columns, one text field in each cell, every column naming its cells from a pattern.
//!
//! The table is laid out in view space: points from the top-left of the page as displayed, after
//! `/Rotate`, so its rows run across the page as the person sees it whatever the page's rotation.
//! The engine maps each cell into user space and adds them all as one undoable edit.
//!
//! [`FieldTable::cells_for`] refuses a table [`add_field`](crate::add_field) would refuse part of
//! (a cell under [`MIN_FIELD_SIDE`], a name with a period, two cells with one name, a name the
//! form already has), so either all of the table is added or none of it is.
use std::collections::HashSet;
use std::fmt;
use crate::MIN_FIELD_SIDE;
/// The most rows a table can have.
pub const MAX_ROWS: usize = 100;
/// The most columns a table can have.
pub const MAX_COLUMNS: usize = 50;
/// The highest number the first row can count from.
pub const MAX_FIRST_ROW: usize = 99_999;
/// One column of a [`FieldTable`].
#[derive(Clone, Debug, PartialEq)]
pub struct TableColumn {
/// How the column names its cells: see [`cell_name`].
pub pattern: String,
/// The column's share of the table's width, against the other columns' (any positive number).
pub width: f64,
}
/// An area divided into rows and columns of text fields.
#[derive(Clone, Debug, PartialEq)]
pub struct FieldTable {
/// The area in view space, `[x0, y0, x1, y1]` with y down, corners in either order.
pub rect: [f64; 4],
pub rows: usize,
pub columns: Vec<TableColumn>,
/// Points left empty between two rows.
pub row_gap: f64,
/// Points left empty between two columns.
pub column_gap: f64,
/// The number the first row's names count from.
pub first_row: usize,
}
/// One cell of a [`FieldTable`]: where its field goes (view space, `[x0, y0, x1, y1]` with y down)
/// and what it is called.
#[derive(Clone, Debug, PartialEq)]
pub struct Cell {
pub rect: [f64; 4],
pub name: String,
}
impl FieldTable {
/// Four rows in one column filling `rect`, named after `base` (see [`column_pattern`]).
pub fn new(rect: [f64; 4], base: &str) -> Self {
FieldTable {
rect,
rows: 4,
columns: vec![TableColumn { pattern: column_pattern(base, 1), width: 1.0 }],
row_gap: 0.0,
column_gap: 0.0,
first_row: 1,
}
}
/// Make the table `n` columns wide, `n` held to 1 to [`MAX_COLUMNS`]. Columns past `n` are
/// dropped; a new one is named after `base` and is as wide as the others are on average, so
/// the widths already set keep their proportions.
pub fn set_columns(&mut self, n: usize, base: &str) {
let n = n.clamp(1, MAX_COLUMNS);
self.columns.truncate(n);
let sum: f64 = self.columns.iter().map(|c| c.width).sum();
let average = sum / self.columns.len().max(1) as f64;
let width = if average.is_finite() && average > 0.0 { average } else { 1.0 };
while self.columns.len() < n {
let pattern = column_pattern(base, self.columns.len() + 1);
self.columns.push(TableColumn { pattern, width });
}
}
/// Where each cell goes and what it is called, row by row. Refuses a table out of bounds or
/// with a cell under [`MIN_FIELD_SIDE`]; the names are not checked (see [`Self::cells_for`]).
pub fn cells(&self) -> Result<Vec<Cell>, TableError> {
let (rows, cols) = (self.rows, self.columns.len());
if !(1..=MAX_ROWS).contains(&rows) {
return Err(TableError::Rows);
}
if !(1..=MAX_COLUMNS).contains(&cols) {
return Err(TableError::Columns);
}
if self.first_row > MAX_FIRST_ROW {
return Err(TableError::FirstRow);
}
let gaps = [self.row_gap, self.column_gap];
if !self.rect.iter().all(|v| v.is_finite())
|| !gaps.iter().all(|g| g.is_finite() && *g >= 0.0)
|| !self.columns.iter().all(|c| c.width.is_finite() && c.width > 0.0)
{
return Err(TableError::Numbers);
}
let r = self.rect;
let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3]));
// `rows` and `cols` are at most 100 and 50 from here on, so the counts below can't overflow
// and are exact as floats.
let row_height = (y1 - y0 - self.row_gap * (rows - 1) as f64) / rows as f64;
let room = x1 - x0 - self.column_gap * (cols - 1) as f64;
let total: f64 = self.columns.iter().map(|c| c.width).sum();
// Corners far enough apart overflow to infinity.
if !(row_height.is_finite() && room.is_finite() && total.is_finite()) {
return Err(TableError::Numbers);
}
// Every number from here on is finite: the corners, gaps and widths are, and their sums were
// checked above.
if row_height < MIN_FIELD_SIDE {
return Err(TableError::RowsTooShort);
}
let mut spans = Vec::with_capacity(cols);
// The widths of the columns so far: each edge is worked out from the start, not from the
// last edge, so rounding doesn't add up across the table.
let mut before = 0.0;
for (c, column) in self.columns.iter().enumerate() {
let left = x0 + room * before / total + c as f64 * self.column_gap;
before += column.width;
// The last column ends at the table's edge, not where rounding puts it.
let right = if c + 1 == cols { x1 } else { x0 + room * before / total + c as f64 * self.column_gap };
if right - left < MIN_FIELD_SIDE {
return Err(TableError::ColumnTooNarrow);
}
spans.push((left, right));
}
let mut cells = Vec::with_capacity(rows * cols);
for row in 0..rows {
let top = y0 + row as f64 * (row_height + self.row_gap);
let bottom = if row + 1 == rows { y1 } else { top + row_height };
if bottom - top < MIN_FIELD_SIDE {
return Err(TableError::RowsTooShort);
}
for (c, (&(left, right), column)) in spans.iter().zip(&self.columns).enumerate() {
// At most MAX_FIRST_ROW + MAX_ROWS * MAX_COLUMNS.
let seq = self.first_row + row * cols + c;
let name = cell_name(&column.pattern, self.first_row + row, c + 1, seq);
cells.push(Cell { rect: [left, top, right, bottom], name });
}
}
Ok(cells)
}
/// [`Self::cells`] for a form whose fields are called `existing` (full names), refusing a
/// name with a period, a name two cells share and a name the form already has, as a field
/// or as the parent of one.
pub fn cells_for<'a>(&self, existing: impl IntoIterator<Item = &'a str>) -> Result<Vec<Cell>, TableError> {
let cells = self.cells()?;
// "a.b" also takes "a": a new field of that name would sit beside the parent of "a.b".
let mut taken = HashSet::new();
for name in existing {
taken.insert(name);
taken.extend(name.match_indices('.').filter_map(|(at, _)| name.get(..at)));
}
let mut seen = HashSet::with_capacity(cells.len());
for cell in &cells {
let name = cell.name.as_str();
if name.contains('.') {
return Err(TableError::Period(cell.name.clone()));
}
if !seen.insert(name) {
return Err(TableError::Twice(cell.name.clone()));
}
if taken.contains(name) {
return Err(TableError::Taken(cell.name.clone()));
}
}
Ok(cells)
}
}
/// The pattern a new table's column `column` (counted from 1) starts with: `{base}_N` for the
/// first, `{base}_c2_N`, `{base}_c3_N`, … after it.
pub fn column_pattern(base: &str, column: usize) -> String {
if column <= 1 { format!("{base}_N") } else { format!("{base}_c{column}_N") }
}
/// The first of "Table1", "Table2", … that no field in `existing` (full names) starts with, so a
/// new table's names are free. One pass over the names, however many the form has.
pub fn free_base<'a>(existing: impl IntoIterator<Item = &'a str>) -> String {
// The K of every "TableK" a name starts with: "Table1", "Table1_3" and "Table1.x" take 1, and
// "Table12_1" takes 12, not 1.
let taken: HashSet<u64> = existing
.into_iter()
.filter_map(|name| {
let rest = name.strip_prefix("Table")?;
let digits = rest.get(..rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()))?;
digits.parse().ok()
})
.collect();
// One of the first `taken.len() + 1` is always free.
let k = (1..=taken.len() as u64 + 1).find(|k| !taken.contains(k)).unwrap_or(1);
format!("Table{k}")
}
/// What may stand on either side of an `N` that is the row number.
const SEPARATORS: [char; 4] = ['_', '-', '/', ':'];
/// The name `pattern` gives the cell in `row` and `column` (`column` counted from 1); `seq` is the
/// cell's number counting along the rows.
///
/// - `{row}`, `{r}`, `{n}` and `#`: the row number; `[row]`, `[r]` and `[n]`: the row number in
/// brackets (`item[N]` → `item[1]`);
/// - `{col}` and `{c}`, `[col]` and `[c]`: the column number;
/// - `{seq}` and `[seq]`: the cell number, counting along the rows from the table's first row
/// number, so a table starting at row 1 numbers its cells 1, 2, 3, …;
/// - with no row or cell number, an `N` or `n` standing alone after `_`, `-`, `/` or `:`
/// (`price_N`, `line-n-total`) is the row number, so the N in `Name` or `NET_N` stays;
/// - with no row or cell number still, the row number goes at the end.
///
/// Braces and brackets match ignoring case. Anything else is kept as written, a period included,
/// though no field name may contain one: [`FieldTable::cells_for`] refuses those.
pub fn cell_name(pattern: &str, row: usize, column: usize, seq: usize) -> String {
let row_number = row.to_string();
let mut out = String::with_capacity(pattern.len() + 8);
// Whether the name has a row or cell number yet.
let mut numbered = false;
let mut rest = pattern.trim();
while let Some(at) = rest.find(['{', '[', '#']) {
// `at` is where an ASCII character starts, so both halves are whole characters.
let (before, from) = rest.split_at(at);
out.push_str(before);
if let Some(after) = from.strip_prefix('#') {
out.push_str(&row_number);
numbered = true;
rest = after;
continue;
}
let (open, close) = if from.starts_with('[') { ('[', ']') } else { ('{', '}') };
let inside = from.get(1..).unwrap_or_default();
let token = inside.split_once(close).and_then(|(word, after)| {
let is = |name: &str| word.eq_ignore_ascii_case(name);
if is("row") || is("r") || is("n") {
Some((row, true, after))
} else if is("seq") {
Some((seq, true, after))
} else if is("col") || is("c") {
Some((column, false, after))
} else {
None
}
});
match token {
Some((n, counts, after)) => {
if open == '[' {
out.push('[');
}
out.push_str(&n.to_string());
if open == '[' {
out.push(']');
}
numbered |= counts;
rest = after;
}
None => {
out.push(open);
rest = inside;
}
}
}
out.push_str(rest);
if numbered {
return out;
}
if out == "N" || out == "n" {
return row_number;
}
let chars: Vec<char> = out.chars().collect();
let mut named = String::with_capacity(out.len() + row_number.len());
for (i, &ch) in chars.iter().enumerate() {
let after_separator = i.checked_sub(1).and_then(|p| chars.get(p)).is_some_and(|c| SEPARATORS.contains(c));
let before_separator = chars.get(i + 1).is_none_or(|c| SEPARATORS.contains(c));
if matches!(ch, 'N' | 'n') && after_separator && before_separator {
named.push_str(&row_number);
numbered = true;
} else {
named.push(ch);
}
}
if !numbered {
named.push_str(&row_number);
}
named
}
/// Why a table can't be added. Each is a mistake a person can make, with the numbers and names
/// apart from the words, so an interface can say it in its own language with [`TableError::said`].
#[derive(Clone, Debug, PartialEq)]
pub enum TableError {
/// Fewer than one row, or more than [`MAX_ROWS`].
Rows,
/// Fewer than one column, or more than [`MAX_COLUMNS`].
Columns,
/// A first row number past [`MAX_FIRST_ROW`].
FirstRow,
/// A corner, gap or width that is not a usable number.
Numbers,
/// The rows come out under [`MIN_FIELD_SIDE`] high.
RowsTooShort,
/// A column comes out under [`MIN_FIELD_SIDE`] wide.
ColumnTooNarrow,
/// A cell's name has a period, which would make it a child of another field.
Period(String),
/// Two cells would have this name.
Twice(String),
/// The form already has a field of this name.
Taken(String),
}
const ROWS: &str = "a table has 1 to {max} rows";
const COLUMNS: &str = "a table has 1 to {max} columns";
const FIRST_ROW: &str = "the first row number can be at most {max}";
const NUMBERS: &str = "the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0";
const ROWS_TOO_SHORT: &str = "the rows are too short: make the area taller, or use fewer rows or less row spacing";
const COLUMN_TOO_NARROW: &str =
"a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing";
const PERIOD: &str = "field names can't contain a period: {name}";
const TWICE: &str = "two cells would both be named {name}";
const TAKEN: &str = "a field named {name} already exists";
/// Every sentence a [`TableError`] says, in English with its holes still in it, for an interface
/// to translate; `wordings_are_said` keeps the list in step with the errors.
pub const WORDINGS: &[&str] = &[ROWS, COLUMNS, FIRST_ROW, NUMBERS, ROWS_TOO_SHORT, COLUMN_TOO_NARROW, PERIOD, TWICE, TAKEN];
/// How an interface says one of our wordings in its own language, holes and all: given the English
/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English.
pub type Words<'a> = &'a dyn Fn(&str) -> String;
/// The [`Words`] that translate nothing: the wording as it is written here.
pub fn in_english(wording: &str) -> String {
wording.to_owned()
}
impl TableError {
/// This error as one sentence, said in the language `words` translates into. The wording is
/// translated and the numbers and names go in afterwards, in a single pass, so a name with
/// braces in it is never read as a hole. With [`in_english`] this is the error's `Display`.
pub fn said(&self, words: Words) -> String {
let limit = |wording: &str, max: usize| say(&words(wording), &[("{max}", max.to_string())]);
// A name goes in quoted, as `add_field` quotes it.
let named = |wording: &str, name: &str| say(&words(wording), &[("{name}", format!("{name:?}"))]);
match self {
TableError::Rows => limit(ROWS, MAX_ROWS),
TableError::Columns => limit(COLUMNS, MAX_COLUMNS),
TableError::FirstRow => limit(FIRST_ROW, MAX_FIRST_ROW),
TableError::Numbers => words(NUMBERS),
TableError::RowsTooShort => words(ROWS_TOO_SHORT),
TableError::ColumnTooNarrow => words(COLUMN_TOO_NARROW),
TableError::Period(name) => named(PERIOD, name),
TableError::Twice(name) => named(TWICE, name),
TableError::Taken(name) => named(TAKEN, name),
}
}
}
impl fmt::Display for TableError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.said(&in_english))
}
}
impl std::error::Error for TableError {}
/// A wording with its holes filled. A hole is a name in braces, such as `{max}`; what is not in
/// `values` is left as it is written.
fn say(wording: &str, values: &[(&str, String)]) -> String {
let mut out = String::with_capacity(wording.len() + 8);
let mut rest = wording;
while let Some(open) = rest.find('{') {
let (before, from_brace) = rest.split_at(open);
out.push_str(before);
// `find` counts from the brace and `}` is one byte, so this is a char boundary.
let Some(close) = from_brace.find('}') else {
out.push_str(from_brace);
return out;
};
let Some(hole) = from_brace.get(..=close) else { return out + from_brace };
match values.iter().find(|(name, _)| *name == hole) {
Some((_, value)) => out.push_str(value),
None => out.push_str(hole),
}
rest = from_brace.get(close + 1..).unwrap_or_default();
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
fn table(rect: [f64; 4], rows: usize, patterns: &[&str]) -> FieldTable {
FieldTable {
rect,
rows,
columns: patterns.iter().map(|p| TableColumn { pattern: (*p).to_owned(), width: 1.0 }).collect(),
row_gap: 0.0,
column_gap: 0.0,
first_row: 1,
}
}
#[test]
fn a_row_number_goes_where_the_pattern_says() {
assert_eq!(cell_name("fieldCategory_N", 1, 1, 1), "fieldCategory_1");
assert_eq!(cell_name("fieldCategory_N", 10, 1, 10), "fieldCategory_10");
assert_eq!(cell_name("row_{n}", 3, 1, 3), "row_3");
assert_eq!(cell_name("item-#", 5, 1, 5), "item-5");
assert_eq!(cell_name("cell[N]", 2, 1, 2), "cell[2]");
assert_eq!(cell_name("N", 4, 1, 4), "4");
assert_eq!(cell_name("line-n-total", 6, 1, 6), "line-6-total");
assert_eq!(cell_name("a:N/b", 7, 1, 7), "a:7/b");
}
#[test]
fn rows_columns_and_cells_have_their_own_tokens() {
assert_eq!(cell_name("item_{row}_{col}", 2, 3, 5), "item_2_3");
assert_eq!(cell_name("cell_R{row}C{col}", 1, 4, 4), "cell_R1C4");
assert_eq!(cell_name("grid[{r},{c}]", 3, 2, 8), "grid[3,2]");
assert_eq!(cell_name("table_r{r}_c{c}", 4, 5, 20), "table_r4_c5");
assert_eq!(cell_name("cell{seq}", 2, 2, 7), "cell7");
assert_eq!(cell_name("cell[seq]", 2, 2, 7), "cell[7]");
// Braces and brackets match ignoring case.
assert_eq!(cell_name("x{ROW}y{Col}z[SEQ]", 2, 3, 9), "x2y3z[9]");
// A column number alone leaves the row to be found or added.
assert_eq!(cell_name("price{c}_N", 2, 3, 9), "price3_2");
assert_eq!(cell_name("col{c}", 1, 2, 2), "col21");
}
#[test]
fn without_a_row_token_the_row_number_goes_at_the_end() {
assert_eq!(cell_name("", 1, 1, 1), "1");
assert_eq!(cell_name(" ", 3, 1, 3), "3");
assert_eq!(cell_name("test_", 1, 1, 1), "test_1");
assert_eq!(cell_name("Phone_Number", 4, 1, 4), "Phone_Number4");
// What isn't a token is kept as written.
assert_eq!(cell_name("{x}_N", 1, 1, 1), "{x}_1");
assert_eq!(cell_name("{row", 1, 1, 1), "{row1");
assert_eq!(cell_name("a]b[", 2, 1, 2), "a]b[2");
// A period is kept, for `cells_for` to refuse.
assert_eq!(cell_name("test.", 1, 1, 1), "test.1");
}
#[test]
fn an_n_inside_a_word_is_not_the_row_number() {
assert_eq!(cell_name("Name_N", 1, 1, 1), "Name_1");
assert_eq!(cell_name("ItemNumber_N", 2, 1, 2), "ItemNumber_2");
assert_eq!(cell_name("NET_AMOUNT_N", 3, 1, 3), "NET_AMOUNT_3");
assert_eq!(cell_name("CustomerName_{row}", 5, 1, 5), "CustomerName_5");
assert_eq!(cell_name("NOTE_#", 6, 1, 6), "NOTE_6");
assert_eq!(cell_name("BankName[N]", 7, 1, 7), "BankName[7]");
assert_eq!(cell_name("PLAN_N", 8, 1, 8), "PLAN_8");
assert_eq!(cell_name("line_n", 9, 1, 9), "line_9");
assert_eq!(cell_name("Prix_é_N", 1, 1, 1), "Prix_é_1");
assert_eq!(cell_name("名前_N", 2, 1, 2), "名前_2");
}
#[test]
fn a_grid_fills_its_area_with_gaps_between_the_cells() {
let mut t = table([100.0, 100.0, 300.0, 300.0], 2, &["cell_{r}_{c}", "cell_{r}_{c}"]);
t.row_gap = 10.0;
t.column_gap = 20.0;
// Width (200 - 20) / 2 = 90, height (200 - 10) / 2 = 95, rows from the top down.
let cells = t.cells().unwrap();
let got: Vec<_> = cells.iter().map(|c| (c.rect, c.name.as_str())).collect();
assert_eq!(
got,
[
([100.0, 100.0, 190.0, 195.0], "cell_1_1"),
([210.0, 100.0, 300.0, 195.0], "cell_1_2"),
([100.0, 205.0, 190.0, 300.0], "cell_2_1"),
([210.0, 205.0, 300.0, 300.0], "cell_2_2"),
]
);
// Corners in either order are the same area.
t.rect = [300.0, 300.0, 100.0, 100.0];
assert_eq!(t.cells().unwrap(), cells);
}
#[test]
fn columns_share_the_width_by_their_widths() {
let mut t = table([100.0, 100.0, 300.0, 300.0], 2, &["desc_N", "price_N"]);
t.columns[0].width = 3.0;
let cells = t.cells().unwrap();
assert_eq!(cells[0].rect, [100.0, 100.0, 250.0, 200.0]);
assert_eq!(cells[1].rect, [250.0, 100.0, 300.0, 200.0]);
assert_eq!((cells[2].name.as_str(), cells[3].name.as_str()), ("desc_2", "price_2"));
// However the shares round, the last column ends at the table's edge.
let mut t = table([0.0, 0.0, 100.0, 50.0], 1, &["a", "b", "c"]);
t.column_gap = 1.0 / 3.0;
assert_eq!(t.cells().unwrap()[2].rect[2], 100.0);
}
#[test]
fn rows_count_from_the_first_row_and_cells_along_the_rows() {
let mut t = table([0.0, 0.0, 100.0, 100.0], 2, &["a_{seq}_{r}", "b_{seq}_{r}"]);
t.first_row = 10;
let names: Vec<_> = t.cells().unwrap().into_iter().map(|c| c.name).collect();
assert_eq!(names, ["a_10_10", "b_11_10", "a_12_11", "b_13_11"]);
}
#[test]
fn a_table_out_of_bounds_is_refused() {
let ok = table([0.0, 0.0, 200.0, 200.0], 4, &["a_N"]);
assert!(ok.cells().is_ok());
let with = |f: &dyn Fn(&mut FieldTable)| {
let mut t = ok.clone();
f(&mut t);
t.cells()
};
assert_eq!(with(&|t| t.rows = 0), Err(TableError::Rows));
assert_eq!(with(&|t| t.rows = MAX_ROWS + 1), Err(TableError::Rows));
assert_eq!(with(&|t| t.columns.clear()), Err(TableError::Columns));
// 200 points hold 50 columns of exactly 4, and 199 don't.
assert_eq!(with(&|t| t.set_columns(MAX_COLUMNS, "x")).map(|c| c.len()), Ok(4 * MAX_COLUMNS));
assert_eq!(
with(&|t| {
t.rect[2] = 199.0;
t.set_columns(MAX_COLUMNS, "x");
}),
Err(TableError::ColumnTooNarrow)
);
assert_eq!(with(&|t| t.columns = vec![t.columns[0].clone(); MAX_COLUMNS + 1]), Err(TableError::Columns));
assert_eq!(with(&|t| t.first_row = MAX_FIRST_ROW + 1), Err(TableError::FirstRow));
assert_eq!(with(&|t| t.rect[0] = f64::NAN), Err(TableError::Numbers));
assert_eq!(with(&|t| t.rect = [-f64::MAX, 0.0, f64::MAX, 200.0]), Err(TableError::Numbers));
assert_eq!(with(&|t| t.row_gap = -1.0), Err(TableError::Numbers));
assert_eq!(with(&|t| t.column_gap = f64::INFINITY), Err(TableError::Numbers));
assert_eq!(with(&|t| t.columns[0].width = 0.0), Err(TableError::Numbers));
// 200 points hold 50 rows of exactly 4, not 51.
assert_eq!(with(&|t| t.rows = 50).map(|c| c.len()), Ok(50));
assert_eq!(with(&|t| t.rows = 51), Err(TableError::RowsTooShort));
assert_eq!(with(&|t| t.row_gap = 70.0), Err(TableError::RowsTooShort));
assert_eq!(with(&|t| t.rect = [0.0, 0.0, 3.0, 200.0]), Err(TableError::ColumnTooNarrow));
// One column squeezed under 4 points by the others' widths.
assert_eq!(
with(&|t| t.columns = vec![TableColumn { pattern: "a_N".into(), width: 1.0 }, TableColumn { pattern: "b_N".into(), width: 99.0 }]),
Err(TableError::ColumnTooNarrow)
);
}
#[test]
fn names_a_form_would_refuse_are_refused_before_anything_is_added() {
let t = table([0.0, 0.0, 200.0, 200.0], 2, &["a_N", "b_N"]);
assert!(t.cells_for(["c_1", "a_10"]).is_ok());
assert_eq!(t.cells_for(["x", "b_2"]), Err(TableError::Taken("b_2".into())));
// The parent of an existing field is taken too.
assert_eq!(t.cells_for(["a_1.street"]), Err(TableError::Taken("a_1".into())));
let same = table([0.0, 0.0, 200.0, 200.0], 2, &["item_N", "item_N"]);
assert_eq!(same.cells_for([]), Err(TableError::Twice("item_1".into())));
let dotted = table([0.0, 0.0, 200.0, 200.0], 2, &["row.{n}"]);
assert_eq!(dotted.cells_for([]), Err(TableError::Period("row.1".into())));
}
#[test]
fn a_new_table_takes_the_first_free_base() {
assert_eq!(free_base([]), "Table1");
assert_eq!(free_base(["Table1_1", "Table1_c2_4"]), "Table2");
assert_eq!(free_base(["Table1", "Table2.x", "Table3_N"]), "Table4");
// "Table12_1" belongs to Table12, not Table1.
assert_eq!(free_base(["Table12_1", "Tables"]), "Table1");
// As fast for a form with thousands of fields: one pass, not one per candidate.
let many: Vec<String> = (1..=20_000).map(|k| format!("Table{k}_1")).collect();
assert_eq!(free_base(many.iter().map(String::as_str)), "Table20001");
let t = FieldTable::new([0.0, 0.0, 200.0, 200.0], &free_base(["Table1_1"]));
let names: Vec<_> = t.cells_for(["Table1_1"]).unwrap().into_iter().map(|c| c.name).collect();
assert_eq!(names, ["Table2_1", "Table2_2", "Table2_3", "Table2_4"]);
}
#[test]
fn added_columns_keep_the_widths_already_set() {
let mut t = FieldTable::new([0.0, 0.0, 400.0, 100.0], "T");
t.columns[0].width = 2.0;
t.set_columns(3, "T");
let got: Vec<_> = t.columns.iter().map(|c| (c.pattern.as_str(), c.width)).collect();
assert_eq!(got, [("T_N", 2.0), ("T_c2_N", 2.0), ("T_c3_N", 2.0)]);
t.columns[2].width = 4.0;
t.set_columns(4, "T");
assert_eq!(t.columns[3].width, 8.0 / 3.0);
t.set_columns(2, "T");
assert_eq!(t.columns.len(), 2);
// Held to the bounds.
t.set_columns(0, "T");
assert_eq!(t.columns.len(), 1);
t.set_columns(usize::MAX, "T");
assert_eq!(t.columns.len(), MAX_COLUMNS);
}
#[test]
fn wordings_are_said() {
let mistakes = [
TableError::Rows,
TableError::Columns,
TableError::FirstRow,
TableError::Numbers,
TableError::RowsTooShort,
TableError::ColumnTooNarrow,
TableError::Period("a.1".into()),
TableError::Twice("a_1".into()),
TableError::Taken("a_1".into()),
];
assert_eq!(WORDINGS.len(), mistakes.len(), "one wording per mistake");
// Each mistake says one of the wordings: translated to a marker, only the values remain.
for e in &mistakes {
let marked = e.said(&|w| format!("<{}>", WORDINGS.iter().position(|x| *x == w).unwrap_or(usize::MAX)));
assert!(!marked.contains(&usize::MAX.to_string()), "{e:?} says a wording that isn't listed");
assert_eq!(e.said(&in_english), e.to_string());
}
assert_eq!(TableError::Rows.to_string(), "a table has 1 to 100 rows");
assert_eq!(TableError::Taken("a_1".into()).to_string(), "a field named \"a_1\" already exists");
// A name goes in as it stands, braces and all, and is never read as a hole.
let other = |w: &str| if w == TAKEN { "{name} existe ya".to_owned() } else { w.to_owned() };
assert_eq!(TableError::Taken("{max}".into()).said(&other), "\"{max}\" existe ya");
}
proptest! {
/// Any pattern names every cell, and never panics.
#[test]
fn any_pattern_gives_a_name(pattern in "(\\PC|[{}\\[\\]#Nn_:/.-]){0,24}", row in 0usize..200_000, column in 1usize..60, seq in 0usize..300_000) {
let name = cell_name(&pattern, row, column, seq);
prop_assert!(!name.is_empty());
prop_assert_eq!(name.trim(), name.as_str());
}
/// Any table either lays out cells of at least the smallest side inside its area, or says
/// why not.
#[test]
fn any_table_lays_out_or_says_why(
rect in prop::array::uniform4(-2000.0f64..2000.0),
rows in 0usize..120,
widths in prop::collection::vec(0.01f64..100.0, 0..60),
row_gap in -1.0f64..50.0,
column_gap in -1.0f64..50.0,
) {
let t = FieldTable {
rect,
rows,
columns: widths.iter().map(|w| TableColumn { pattern: "a{seq}".into(), width: *w }).collect(),
row_gap,
column_gap,
first_row: 1,
};
if let Ok(cells) = t.cells_for([]) {
prop_assert_eq!(cells.len(), rows * widths.len());
let (x0, y0, x1, y1) = (rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3]));
for c in &cells {
let [l, t, r, b] = c.rect;
prop_assert!(r - l >= MIN_FIELD_SIDE && b - t >= MIN_FIELD_SIDE, "{:?}", c);
prop_assert!(l >= x0 - 1e-6 && r <= x1 + 1e-6 && t >= y0 - 1e-6 && b <= y1 + 1e-6, "{:?}", c);
}
}
}
}
}
+18 -1
View File
@@ -2810,6 +2810,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let mut open_props: Option<(usize, usize)> = None;
let mut field_props = false;
let mut field_placed = false;
let mut open_table: Option<(usize, [f64; 4])> = None;
// The table being set up in its window, shown on its own document's page.
let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form)));
let can_modify = doc.allows_modification();
if preparing {
crate::prepare::after_refresh(view, &form);
@@ -3152,9 +3155,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
QuickTool::Field(f) => Some(f),
_ => None,
};
let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, can_modify, view);
// While a table is being set up, nothing else is drawn or moved.
let allowed = can_modify && table_preview.is_none() && open_table.is_none();
let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, allowed, view);
field_props |= o.properties;
field_placed |= o.placed;
open_table = open_table.or(o.table);
(o.consumed || field_tool.is_some(), false)
} else {
use crate::forms_ui::FieldPointer;
@@ -3467,6 +3473,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
if preparing {
crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view);
if let Some(preview) = &table_preview {
crate::field_table_ui::paint_preview(painter, &xf, i, preview);
}
if calc_order {
let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str());
if let Some(name) = crate::calc_order_ui::paint_page(ui, painter, &xf, i, info, &form, &view.calc_order, generation, selected) {
@@ -4080,6 +4089,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
app.boxes_draft.seeded = None;
app.dialog = Some(crate::Dialog::PageBoxes);
}
if let Some((page, rect)) = open_table {
app.field_table_draft = Some(crate::field_table_ui::FieldTableDraft::new(app.views[index].id, page, rect, &form, generation));
app.dialog = Some(crate::Dialog::FieldTable);
// The window lasts while the form is prepared. Drawn with the tool picked but its panel
// closed, the tool goes back to Select above, so the panel opens to keep it prepared.
app.left = crate::LeftPanel::Tool("form");
app.reveal_tools();
}
if let Some(n) = form_notice {
match n {
crate::forms_ui::FormNotice::Security => app.notify_tr("The document's security settings don't allow filling in form fields"),
+11
View File
@@ -25,6 +25,14 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
if app.dialog != Some(Dialog::WordCount) {
app.word_count_draft.cancel();
}
if app.dialog != Some(Dialog::FieldTable) {
app.field_table_draft = None;
}
// A movable window, not this modal: the page under it shows the table as it changes.
if app.dialog == Some(Dialog::FieldTable) {
crate::field_table_ui::show(app, ctx);
return;
}
let Some(dialog) = app.dialog else {
app.props_draft = None;
app.view_draft = None;
@@ -719,6 +727,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
});
return;
}
// Never reached: `dialogs` shows this movable window above and returns before the modal.
// The arm keeps the match exhaustive.
Dialog::FieldTable => close = true,
Dialog::Optimize => {
let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t);
optimize_now = ok;
+512
View File
@@ -0,0 +1,512 @@
//! Prepare a form ▸ Text field table: a movable window that divides the area drawn on a page into
//! rows and columns of text fields, while the page shows the table as it changes. The table is
//! the engine's [`FieldTable`]; this is its window.
//!
//! The window is not modal, so that the page stays in view, but it belongs to one page of one
//! document as it was when the window opened: it closes when that document stops being the one
//! prepared or changes in any way (a page rotated, moved or deleted, an edit undone), and adds the
//! table there.
use std::ops::RangeInclusive;
use std::sync::Arc;
use egui::{Align, Color32, CornerRadius, Layout, Rect, RichText, Sense, Stroke, pos2, vec2};
use pdfcraft_engine::field_table::{self, Cell, FieldTable, MAX_COLUMNS, MAX_FIRST_ROW, MAX_ROWS, TableError};
use pdfcraft_engine::{DocId, Edit, FormField};
use pdfcraft_render::PageInfo;
use crate::canvas::PageXform;
use crate::{Dialog, PdfCraftApp, i18n, theme, widgets};
const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
const ERROR_RED: Color32 = Color32::from_rgb(0xD1, 0x3B, 0x3B);
/// A divider can't be dragged to leave a column less than this share of the table, or less than a
/// quarter of what the two columns it divides have between them when that is smaller.
const MIN_SHARE: f64 = 0.04;
/// The table being set up in the window, and where it goes.
#[derive(Clone, Debug)]
pub struct FieldTableDraft {
/// The document the table goes in.
pub doc: DocId,
/// The page it goes on, 0-based.
pub page: usize,
pub table: FieldTable,
/// What added columns are named after: the first free "TableK" when the window opened.
base: String,
/// The document's edit generation when the window opened: any edit since closes it.
generation: u64,
/// The table as last laid out, kept until the table changes.
laid: Option<Arc<Laid>>,
/// A field in the window had the keyboard last frame, so Escape leaves the field.
typing: bool,
}
/// Two drafts are the same table in the same place; what is kept for the next frame isn't part of it.
impl PartialEq for FieldTableDraft {
fn eq(&self, other: &Self) -> bool {
(self.doc, self.page, &self.table, &self.base, self.generation) == (other.doc, other.page, &other.table, &other.base, other.generation)
}
}
/// A table laid out and checked once, for every frame it stays the same: the cells the page and the
/// window show, and the edit Add fields applies. Up to 100 × 50 cells, so not every frame.
#[derive(Debug)]
pub(crate) struct Laid {
table: FieldTable,
cells: Result<Vec<Cell>, TableError>,
edit: Result<Edit, TableError>,
}
/// What the page shows of the table being set up: its cells, or why it can't be laid out.
pub(crate) struct Preview {
page: usize,
laid: Arc<Laid>,
}
/// How the window was left this frame.
enum Choice {
Add,
Cancel,
}
impl FieldTableDraft {
/// A table filling `rect` (view space) on `page` of a document at edit `generation`, named after
/// the first base `form` leaves free.
pub(crate) fn new(doc: DocId, page: usize, rect: [f64; 4], form: &[FormField], generation: u64) -> Self {
let base = field_table::free_base(form.iter().map(|f| f.name.as_str()));
FieldTableDraft { doc, page, table: FieldTable::new(rect, &base), base, generation, laid: None, typing: false }
}
/// The table laid out on its page (`info`, in a form whose fields are `form`), worked out again
/// only when the table has changed since. The form can't change under it: an edit closes the
/// window.
pub(crate) fn laid(&mut self, info: &PageInfo, form: &[FormField]) -> Arc<Laid> {
if let Some(laid) = self.laid.as_ref().filter(|l| l.table == self.table) {
return Arc::clone(laid);
}
let laid =
Arc::new(Laid { table: self.table.clone(), cells: self.table.cells(), edit: field_table::edit(&self.table, self.page, info, form) });
self.laid = Some(Arc::clone(&laid));
laid
}
/// What the page shows of this table this frame.
pub(crate) fn preview(&mut self, info: &PageInfo, form: &[FormField]) -> Preview {
Preview { page: self.page, laid: self.laid(info, form) }
}
/// Each column's share of the table's width, together 1.
fn shares(&self) -> Vec<f64> {
let total: f64 = self.table.columns.iter().map(|c| c.width).sum();
let n = self.table.columns.len().max(1) as f64;
self.table.columns.iter().map(|c| if total > 0.0 && total.is_finite() { c.width / total } else { 1.0 / n }).collect()
}
/// Move the divider after column `i` (0-based) by `delta`, a share of the table's width. The
/// two columns it divides keep [`MIN_SHARE`] each, or a quarter of what they have between them
/// when that is less, so narrow columns can still be resized; the other columns keep theirs.
fn move_divider(&mut self, i: usize, delta: f64) {
let total: f64 = self.table.columns.iter().map(|c| c.width).sum();
let (Some(a), Some(b)) = (self.table.columns.get(i).map(|c| c.width), self.table.columns.get(i + 1).map(|c| c.width)) else { return };
if !(total > 0.0 && total.is_finite() && delta.is_finite()) {
return;
}
let pair = (a + b) / total;
// At most half the pair, so the bounds never cross; `max` then `min` all the same, never
// `clamp`, which panics if they do.
let least = MIN_SHARE.min(pair / 4.0);
let left = (a / total + delta).max(least).min(pair - least);
if let Some(c) = self.table.columns.get_mut(i) {
c.width = left * total;
}
if let Some(c) = self.table.columns.get_mut(i + 1) {
c.width = (pair - left) * total;
}
}
/// Give every column the same width: their widths are shares, so the same number does it.
fn distribute_evenly(&mut self) {
for c in &mut self.table.columns {
c.width = 1.0;
}
}
}
/// Show the window while [`Dialog::FieldTable`] is open, and add the table when asked to.
pub(crate) fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
// The window belongs to one page of one document, while that document is being prepared.
let active = app.active_ids().map(|(_, id)| id);
let preparing = app.is_preparing();
let belongs = app.field_table_draft.as_ref().is_some_and(|d| {
active == Some(d.doc)
&& preparing
&& app
.session
.get(d.doc)
.is_some_and(|doc| doc.edit_generation() == d.generation && d.page < doc.info.pages.len() && doc.allows_modification())
});
if !belongs {
close(app);
return;
}
// Both there, as `belongs` says; the fields are borrowed apart, so the window can change the
// table while it reads the document.
let Some(draft) = app.field_table_draft.as_mut() else { return };
let Some((info, form)) = app.session.get(draft.doc).and_then(|doc| Some((doc.info.pages.get(draft.page)?, &doc.form))) else { return };
let t = theme::Tokens::get(ctx);
let mut open = true;
let mut choice = None;
let mut laid = None;
egui::Window::new(tl!("Text Field Table"))
.id(egui::Id::new("field_table_window"))
.open(&mut open)
.collapsible(false)
.resizable(false)
.order(egui::Order::Foreground)
.min_width(440.0)
.max_width(440.0)
.default_pos(egui::pos2(120.0, 100.0))
.show(ctx, |ui| {
widgets::begin_dialog(ui);
let (c, l) = body(ui, draft, info, form, &t);
choice = c;
laid = Some(l);
});
// Escape closes it, as it closes a modal dialog, unless a field had the keyboard: then Escape
// only leaves the field, and what was set up stays. Enter is left to the field being typed in.
let escape = !draft.typing && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
draft.typing = ctx.egui_wants_keyboard_input();
match choice {
_ if !open || escape => close(app),
Some(Choice::Cancel) => close(app),
// Added, the window closes; refused, it stays as it is, with the notice saying why.
Some(Choice::Add) => {
if let Some(Ok(edit)) = laid.as_deref().map(|l| l.edit.clone())
&& app.apply_edit(edit)
{
close(app);
}
}
None => {}
}
}
/// Drop the table being set up, and the dialog with it if it is still the open one.
fn close(app: &mut PdfCraftApp) {
app.field_table_draft = None;
if app.dialog == Some(Dialog::FieldTable) {
app.dialog = None;
}
}
/// The window's controls, the names the table will have, and why it can't be added if it can't.
/// Returns what was chosen and the table as it now stands, laid out and checked.
fn body(ui: &mut egui::Ui, d: &mut FieldTableDraft, info: &PageInfo, form: &[FormField], t: &theme::Tokens) -> (Option<Choice>, Arc<Laid>) {
let columns = d.table.columns.len();
egui::Grid::new("field_table_grid").num_columns(2).spacing([14.0, 8.0]).min_col_width(120.0).show(ui, |ui| {
ui.label(tl!("Rows"));
stepper(ui, &mut d.table.rows, 1..=MAX_ROWS, 1.0, "");
ui.end_row();
ui.label(tl!("Columns"));
let mut n = columns;
if stepper(ui, &mut n, 1..=MAX_COLUMNS, 1.0, "") {
d.table.set_columns(n, &d.base);
}
ui.end_row();
ui.label(tl!("Row spacing"));
stepper(ui, &mut d.table.row_gap, 0.0..=100.0, 1.0, " pt");
ui.end_row();
ui.label(tl!("Column spacing"));
ui.add_enabled_ui(columns > 1, |ui| stepper(ui, &mut d.table.column_gap, 0.0..=100.0, 1.0, " pt"));
ui.end_row();
ui.label(tl!("First row number"));
ui.add(egui::DragValue::new(&mut d.table.first_row).range(0..=MAX_FIRST_ROW));
ui.end_row();
});
ui.add_space(8.0);
ui.separator();
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Column widths")).strong());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.add_enabled_ui(d.table.columns.len() > 1, |ui| {
if widgets::pill_button(ui, tl!("Distribute evenly"), false).clicked() {
d.distribute_evenly();
}
});
});
});
ui.add_space(4.0);
column_bar(ui, d, t);
ui.add_space(8.0);
ui.label(RichText::new(tl!("Field names")).strong());
// How the names are worked out. Its tokens are the same in every language.
let hint = tl!("N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last.");
ui.add(egui::Label::new(RichText::new(hint).color(t.text_muted).size(11.0)).wrap());
ui.add_space(4.0);
// The widths shown don't depend on the names being typed below.
let layout = d.laid(info, form);
let shares = d.shares();
let scrolls = d.table.columns.len() > 3;
let mut column_fields = |ui: &mut egui::Ui| {
for (c, column) in d.table.columns.iter_mut().enumerate() {
let width = layout.cells.as_ref().ok().and_then(|cells| cells.get(c)).map(|cell| cell.rect[2] - cell.rect[0]);
let share = shares.get(c).copied().unwrap_or_default();
ui.horizontal(|ui| {
ui.label(RichText::new(column_label(c)).strong());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let size = width.map_or_else(|| "—".to_owned(), |w| format!("{w:.1} pt · {:.0}%", share * 100.0));
ui.label(RichText::new(size).color(t.text_muted).size(11.0));
});
});
let full = ui.available_width();
ui.add(widgets::line(&mut column.pattern).desired_width(full).id_salt(("field table column", c)));
ui.add_space(4.0);
}
};
if scrolls {
egui::ScrollArea::vertical().max_height(180.0).show(ui, column_fields);
} else {
column_fields(ui);
}
ui.add_space(8.0);
// The names, and the edit Add fields applies, from the table as it now stands.
let laid = d.laid(info, form);
if let Ok(cells) = &laid.cells {
let names: Vec<&str> = cells.iter().map(|c| c.name.as_str()).collect();
let shown = match names.as_slice() {
[] => String::new(),
[one] => (*one).to_owned(),
[first, second] => format!("{first}, {second}"),
[first, second, .., last] => format!("{first}, {second} … {last}"),
};
ui.horizontal(|ui| {
ui.label(RichText::new(i18n::trn(i18n::current(), names.len() as u64, "{n} field", "{n} fields")).color(t.text_muted));
ui.add(egui::Label::new(RichText::new(shown).font(theme::medium(12.0))).truncate());
});
}
if let Err(e) = &laid.edit {
ui.add(egui::Label::new(RichText::new(e.said(&i18n::words)).color(ERROR_RED).size(11.0)).wrap());
}
ui.add_space(14.0);
let mut choice = None;
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled_ui(laid.edit.is_ok(), |ui| widgets::pill_button(ui, tl!("Add fields"), true)).inner.clicked() {
choice = Some(Choice::Add);
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
choice = Some(Choice::Cancel);
}
});
(choice, laid)
}
/// "Column 2" for the column at 0-based `index`.
fn column_label(index: usize) -> String {
i18n::fmt(tl!("Column {n}"), &[("n", &index.saturating_add(1).to_string())])
}
/// A number with minus and plus buttons either side, held to `range`, moving by `step`. Whether it
/// changed.
fn stepper<N: egui::emath::Numeric>(ui: &mut egui::Ui, value: &mut N, range: RangeInclusive<N>, step: f64, suffix: &str) -> bool {
let before = *value;
let (low, high) = (range.start().to_f64(), range.end().to_f64());
ui.horizontal(|ui| {
if ui.button("-").clicked() {
*value = N::from_f64((value.to_f64() - step).max(low));
}
ui.add(egui::DragValue::new(value).range(range).speed(step / 2.0).suffix(suffix));
if ui.button("+").clicked() {
*value = N::from_f64((value.to_f64() + step).min(high));
}
});
*value != before
}
/// The columns as a bar with a divider between each two, dragged (or moved with the arrow keys once
/// focused) to share the width differently.
fn column_bar(ui: &mut egui::Ui, d: &mut FieldTableDraft, t: &theme::Tokens) {
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width().max(100.0), 28.0), Sense::hover());
let edges = |shares: &[f64]| -> Vec<f32> {
let mut x = bar.left();
shares
.iter()
.map(|s| {
x += *s as f32 * bar.width();
x
})
.collect()
};
let n = d.table.columns.len();
// Wide enough to grab, never reaching past the middle of a column.
let reach = (bar.width() / n.max(1) as f32 / 2.0).clamp(2.0, 8.0);
let mut active = None;
let ends = edges(&d.shares());
for (i, &x) in ends.iter().enumerate().take(n.saturating_sub(1)) {
let hit = Rect::from_min_max(pos2(x - reach, bar.top()), pos2(x + reach, bar.bottom()));
let resp = ui.interact(hit, ui.id().with(("field table divider", i)), Sense::click_and_drag());
let share = d.shares().get(i).copied().unwrap_or_default();
resp.widget_info(|| egui::WidgetInfo::slider(true, (share * 100.0).round(), column_label(i)));
let mut delta = f64::from(resp.drag_delta().x) / f64::from(bar.width());
if resp.has_focus() {
ui.input_mut(|input| {
if input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) {
delta -= 0.01;
}
if input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) {
delta += 0.01;
}
});
}
if delta != 0.0 {
d.move_divider(i, delta);
}
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
if resp.hovered() || resp.dragged() || resp.has_focus() {
active = Some(i);
}
}
let painter = ui.painter_at(bar);
let shares = d.shares();
let mut left = bar.left();
for (c, (&right, share)) in edges(&shares).iter().zip(&shares).enumerate() {
// The last column ends at the bar's edge, whatever rounding says.
let right = if c + 1 == n { bar.right() } else { right.min(bar.right()) };
let column = Rect::from_min_max(pos2(left, bar.top()), pos2(right, bar.bottom()));
painter.rect_filled(column, CornerRadius::ZERO, SELECT_BLUE.gamma_multiply(if c % 2 == 0 { 0.11 } else { 0.055 }));
if column.width() > 32.0 {
painter.text(column.center(), egui::Align2::CENTER_CENTER, format!("{:.0}%", share * 100.0), theme::medium(11.0), t.text);
}
left = right;
}
painter.rect_stroke(bar, CornerRadius::same(3), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
for (i, &x) in edges(&shares).iter().enumerate().take(n.saturating_sub(1)) {
let stroke = if active == Some(i) { Stroke::new(3.0, SELECT_BLUE) } else { Stroke::new(1.5, t.border) };
painter.line_segment([pos2(x, bar.top() + 1.0), pos2(x, bar.bottom() - 1.0)], stroke);
}
}
/// The table being set up, on its page: each cell with its name, or the table's outline in red
/// with the reason it can't be laid out. Only the cells in view are drawn, and only those with room
/// for it get their name: a table can have 5,000 cells.
pub(crate) fn paint_preview(painter: &egui::Painter, xf: &PageXform, page: usize, preview: &Preview) {
if preview.page != page {
return;
}
let zoom = (xf.rect.width() / xf.pw.max(1.0)).clamp(0.5, 3.0);
let font = theme::regular((9.0 * zoom).clamp(7.0, 13.0));
let on_screen = |r: [f64; 4]| xf.view_rect([r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]);
let outline = on_screen(preview.laid.table.rect);
let visible = painter.clip_rect();
// A tag of white `text` in the middle of `area`, kept inside it.
let tag = |area: Rect, text: String, fill: Color32| {
let galley = painter.layout_no_wrap(text, font.clone(), Color32::WHITE);
let tag = Rect::from_center_size(area.center(), galley.size() + vec2(6.0, 2.0));
let inside = painter.with_clip_rect(painter.clip_rect().intersect(area));
inside.rect_filled(tag, CornerRadius::same(1), fill);
inside.galley(tag.min + vec2(3.0, 1.0), galley, Color32::WHITE);
};
match &preview.laid.cells {
Ok(cells) => {
painter.rect_stroke(outline, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.3)), egui::StrokeKind::Outside);
for cell in cells {
let r = on_screen(cell.rect);
if !visible.intersects(r) {
continue;
}
painter.rect_filled(r, CornerRadius::ZERO, crate::prepare::FIELD_FILL);
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.5, SELECT_BLUE), egui::StrokeKind::Inside);
if r.height() >= font.size + 2.0 && r.width() >= 24.0 {
tag(r, cell.name.clone(), SELECT_BLUE);
}
}
}
Err(e) => {
painter.rect_stroke(outline, CornerRadius::ZERO, Stroke::new(1.5, ERROR_RED), egui::StrokeKind::Inside);
tag(outline, e.said(&i18n::words), ERROR_RED);
}
}
}
#[cfg(test)]
mod tests {
use pdfcraft_engine::field_table::TableColumn;
use super::*;
fn draft(columns: usize) -> FieldTableDraft {
let mut d = FieldTableDraft::new(DocId(1), 0, [0.0, 0.0, 400.0, 200.0], &[], 0);
d.table.set_columns(columns, &d.base);
d
}
/// Thirty even columns leave each pair less than twice the least share a divider keeps: the
/// two bounds crossed, and `f64::clamp` panicked on the first drag.
#[test]
fn dragging_a_divider_between_narrow_columns_never_panics() {
for columns in [2, 25, 26, 30, MAX_COLUMNS] {
for delta in [-1.0, -0.01, 0.0, 0.01, 1.0, f64::NAN, f64::INFINITY] {
let mut d = draft(columns);
let before: f64 = d.table.columns.iter().map(|c| c.width).sum();
d.move_divider(0, delta);
d.move_divider(columns - 2, delta);
let after: f64 = d.table.columns.iter().map(|c| c.width).sum();
assert!((after - before).abs() < 1e-9, "{columns} columns, {delta}: the total width stays");
assert!(d.table.columns.iter().all(|c| c.width > 0.0), "{columns} columns, {delta}: no column vanishes");
}
// And the divider still moves.
let mut d = draft(columns);
d.move_divider(0, -0.01);
assert!(d.table.columns[0].width < d.table.columns[1].width, "{columns} columns: {:?}", d.shares());
}
}
#[test]
fn a_divider_moves_only_the_two_columns_it_divides() {
let mut d = draft(3);
d.move_divider(0, 0.1);
let shares = d.shares();
assert!((shares[0] - (1.0 / 3.0 + 0.1)).abs() < 1e-9 && (shares[1] - (1.0 / 3.0 - 0.1)).abs() < 1e-9, "{shares:?}");
assert!((shares[2] - 1.0 / 3.0).abs() < 1e-9);
// Dragged past its neighbour, a column stops at the least share; past the end, nothing.
d.move_divider(0, 1.0);
assert!((d.shares()[1] - MIN_SHARE).abs() < 1e-9);
let before = d.clone();
d.move_divider(2, 0.5);
assert_eq!(d, before);
d.distribute_evenly();
assert!(d.table.columns.iter().all(|c| c.width == 1.0));
}
/// The window and the page share one layout of the table, worked out again only when the table
/// changes: at 100 × 50 cells, laying it out several times a frame was 25,000 names a frame.
#[test]
fn the_layout_is_worked_out_again_only_when_the_table_changes() {
let info = PageInfo { width: 400.0, height: 300.0, label: "1".into(), crop: [0.0, 0.0, 400.0, 300.0], rotation: 0 };
let mut d = draft(2);
let first = d.laid(&info, &[]);
assert!(Arc::ptr_eq(&first, &d.laid(&info, &[])), "the same table, the same layout");
assert!(Arc::ptr_eq(&first, &d.preview(&info, &[]).laid), "the page shares it");
d.table.rows = 5;
let second = d.laid(&info, &[]);
assert!(!Arc::ptr_eq(&first, &second), "a changed table is laid out again");
assert_eq!(second.cells.as_ref().map(Vec::len), Ok(10));
assert!(second.edit.is_ok());
}
#[test]
fn added_columns_are_named_after_the_tables_base() {
let mut d = FieldTableDraft::new(DocId(1), 0, [0.0, 0.0, 400.0, 200.0], &[], 0);
d.table.set_columns(2, &d.base);
let patterns: Vec<_> = d.table.columns.iter().map(|c: &TableColumn| c.pattern.as_str()).collect();
assert_eq!(patterns, ["Table1_N", "Table1_c2_N"]);
}
}
+20
View File
@@ -2757,3 +2757,23 @@ certificate Version الإصدار
Paragraph spacing تباعد الفقرات
Less أقل
Other options خيارات أخرى
# Prepare a form ▸ Text field table
Add text field table إضافة جدول حقول نصية
Text field table جدول حقول نصية
Text Field Table جدول حقول نصية
Columns الأعمدة
Row spacing المسافة بين الصفوف
Column spacing المسافة بين الأعمدة
First row number رقم الصف الأول
Column widths عروض الأعمدة
Distribute evenly توزيع بالتساوي
Field names أسماء الحقول
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N أو {row} هو رقم الصف، و{col} رقم العمود، و{seq} رقم الخلية. إذا لم يوجد أي منها، يُضاف رقم الصف في النهاية.
Column {n} العمود {n}
Add fields إضافة الحقول
the rows are too short: make the area taller, or use fewer rows or less row spacing الصفوف قصيرة جدًا: اجعل المنطقة أطول، أو استخدم صفوفًا أقل أو مسافة أقل بين الصفوف
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing أحد الأعمدة ضيق جدًا: اجعل المنطقة أعرض، أو امنح العمود عرضًا أكبر، أو استخدم أعمدة أقل أو مسافة أقل بين الأعمدة
field names can't contain a period: {name} لا يمكن أن تحتوي أسماء الحقول على نقطة: {name}
two cells would both be named {name} ستحمل خليتان الاسم نفسه {name}
a field named {name} already exists يوجد حقل باسم {name} بالفعل
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20
View File
@@ -2444,3 +2444,23 @@ animation effects Off Изключени
Paragraph spacing Разстояние между абзаците
Less По-малко
Other options Други опции
# Prepare a form ▸ Text field table
Add text field table Добави таблица от текстови полета
Text field table Таблица от текстови полета
Text Field Table Таблица от текстови полета
Columns Колони
Row spacing Разстояние между редовете
Column spacing Разстояние между колоните
First row number Номер на първия ред
Column widths Ширини на колоните
Distribute evenly Разпредели равномерно
Field names Имена на полетата
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N или {row} е номерът на реда, {col} – на колоната, {seq} – на клетката. Без тях номерът на реда се добавя накрая.
Column {n} Колона {n}
Add fields Добави полета
the rows are too short: make the area taller, or use fewer rows or less row spacing редовете са твърде ниски: направете областта по-висока или използвайте по-малко редове или по-малко разстояние между тях
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing колона е твърде тясна: направете областта по-широка, дайте ѝ повече място или използвайте по-малко колони или по-малко разстояние между тях
field names can't contain a period: {name} имената на полетата не може да съдържат точка: {name}
two cells would both be named {name} две клетки биха имали едно и също име {name}
a field named {name} already exists вече съществува поле с име {name}
Can't render this file because it contains an unexpected character in line 8 and column 16.
+20
View File
@@ -2555,3 +2555,23 @@ animation effects Off Vypnuto
Paragraph spacing Mezera mezi odstavci
Less Méně
Other options Další možnosti
# Prepare a form ▸ Text field table
Add text field table Přidat tabulku textových polí
Text field table Tabulka textových polí
Text Field Table Tabulka textových polí
Columns Sloupce
Row spacing Mezera mezi řádky
Column spacing Mezera mezi sloupci
First row number Číslo prvního řádku
Column widths Šířky sloupců
Distribute evenly Rozložit rovnoměrně
Field names Názvy polí
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N nebo {row} je číslo řádku, {col} číslo sloupce a {seq} číslo buňky. Bez nich se číslo řádku přidá na konec.
Column {n} Sloupec {n}
Add fields Přidat pole
the rows are too short: make the area taller, or use fewer rows or less row spacing řádky jsou příliš nízké: zvětšete výšku oblasti nebo použijte méně řádků či menší mezeru mezi řádky
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing sloupec je příliš úzký: rozšiřte oblast, dejte sloupci více místa nebo použijte méně sloupců či menší mezeru mezi sloupci
field names can't contain a period: {name} názvy polí nesmějí obsahovat tečku: {name}
two cells would both be named {name} dvě buňky by se jmenovaly {name}
a field named {name} already exists pole s názvem {name} už existuje
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20
View File
@@ -2482,3 +2482,23 @@ animation effects Off Aus
Paragraph spacing Absatzabstand
Less Weniger
Other options Weitere Optionen
# Prepare a form ▸ Text field table
Add text field table Textfeldtabelle hinzufügen
Text field table Textfeldtabelle
Text Field Table Textfeldtabelle
Columns Spalten
Row spacing Zeilenabstand
Column spacing Spaltenabstand
First row number Erste Zeilennummer
Column widths Spaltenbreiten
Distribute evenly Gleichmäßig verteilen
Field names Feldnamen
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N oder {row} ist die Zeilennummer, {col} die Spaltennummer, {seq} die Zellennummer. Ohne eines davon steht die Zeilennummer am Ende.
Column {n} Spalte {n}
Add fields Felder hinzufügen
the rows are too short: make the area taller, or use fewer rows or less row spacing die Zeilen sind zu niedrig: Mach den Bereich höher oder verwende weniger Zeilen oder weniger Zeilenabstand
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing eine Spalte ist zu schmal: Mach den Bereich breiter, gib der Spalte mehr Breite oder verwende weniger Spalten oder weniger Spaltenabstand
field names can't contain a period: {name} Feldnamen dürfen keinen Punkt enthalten: {name}
two cells would both be named {name} zwei Zellen hätten beide den Namen {name}
a field named {name} already exists ein Feld namens {name} ist bereits vorhanden
Can't render this file because it contains an unexpected character in line 6 and column 51.
+20
View File
@@ -2487,3 +2487,23 @@ animation effects Off Ανενεργά
Paragraph spacing Απόσταση παραγράφων
Less Λιγότερα
Other options Άλλες επιλογές
# Prepare a form ▸ Text field table
Add text field table Προσθήκη πίνακα πεδίων κειμένου
Text field table Πίνακας πεδίων κειμένου
Text Field Table Πίνακας πεδίων κειμένου
Columns Στήλες
Row spacing Απόσταση γραμμών
Column spacing Απόσταση στηλών
First row number Αριθμός πρώτης γραμμής
Column widths Πλάτη στηλών
Distribute evenly Ομοιόμορφη κατανομή
Field names Ονόματα πεδίων
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. Το N ή το {row} είναι ο αριθμός γραμμής, το {col} ο αριθμός στήλης και το {seq} ο αριθμός κελιού. Χωρίς κανένα από αυτά, ο αριθμός γραμμής μπαίνει στο τέλος.
Column {n} Στήλη {n}
Add fields Προσθήκη πεδίων
the rows are too short: make the area taller, or use fewer rows or less row spacing οι γραμμές είναι πολύ χαμηλές: κάντε την περιοχή ψηλότερη ή χρησιμοποιήστε λιγότερες γραμμές ή μικρότερη απόσταση γραμμών
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing μια στήλη είναι πολύ στενή: κάντε την περιοχή φαρδύτερη, δώστε στη στήλη περισσότερο πλάτος ή χρησιμοποιήστε λιγότερες στήλες ή μικρότερη απόσταση στηλών
field names can't contain a period: {name} τα ονόματα πεδίων δεν μπορούν να περιέχουν τελεία: {name}
two cells would both be named {name} δύο κελιά θα είχαν το ίδιο όνομα {name}
a field named {name} already exists υπάρχει ήδη πεδίο με όνομα {name}
Can't render this file because it contains an unexpected character in line 477 and column 9.
+24
View File
@@ -2621,3 +2621,27 @@ animation effects Off Desactivados
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original.
# Prepare a form ▸ Text field table
Add text field table Añadir tabla de campos de texto
Text field table Tabla de campos de texto
Text Field Table Tabla de campos de texto
Columns Columnas
Row spacing Espacio entre filas
Column spacing Espacio entre columnas
First row number Número de la primera fila
Column widths Anchos de columna
Distribute evenly Repartir por igual
Field names Nombres de los campos
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N o {row} es el número de fila, {col} el de columna y {seq} el de celda. Si no hay ninguno, el número de fila va al final.
Column {n} Columna {n}
Add fields Añadir campos
a table has 1 to {max} rows una tabla tiene de 1 a {max} filas
a table has 1 to {max} columns una tabla tiene de 1 a {max} columnas
the first row number can be at most {max} el número de la primera fila puede ser como mucho {max}
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 el área, el espaciado y los anchos de columna deben ser números, el espaciado 0 o más y cada ancho mayor que 0
the rows are too short: make the area taller, or use fewer rows or less row spacing las filas son demasiado bajas: haz el área más alta o usa menos filas o menos espacio entre filas
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing una columna es demasiado estrecha: haz el área más ancha, dale más anchura a la columna o usa menos columnas o menos espacio entre columnas
field names can't contain a period: {name} los nombres de campo no pueden contener un punto: {name}
two cells would both be named {name} dos celdas se llamarían {name}
a field named {name} already exists ya existe un campo llamado {name}
Can't render this file because it contains an unexpected character in line 6 and column 60.
+20
View File
@@ -2481,3 +2481,23 @@ animation effects Off Désactivés
Paragraph spacing Espacement des paragraphes
Less Moins
Other options Autres options
# Prepare a form ▸ Text field table
Add text field table Ajouter un tableau de champs de texte
Text field table Tableau de champs de texte
Text Field Table Tableau de champs de texte
Columns Colonnes
Row spacing Espacement des lignes
Column spacing Espacement des colonnes
First row number Numéro de la première ligne
Column widths Largeurs des colonnes
Distribute evenly Répartir uniformément
Field names Noms des champs
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N ou {row} est le numéro de ligne, {col} le numéro de colonne, {seq} le numéro de cellule. Sans aucun d'eux, le numéro de ligne est ajouté à la fin.
Column {n} Colonne {n}
Add fields Ajouter les champs
the rows are too short: make the area taller, or use fewer rows or less row spacing les lignes sont trop basses : agrandissez la zone en hauteur, ou utilisez moins de lignes ou un espacement des lignes plus petit
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing une colonne est trop étroite : élargissez la zone, donnez-lui plus de largeur, ou utilisez moins de colonnes ou un espacement des colonnes plus petit
field names can't contain a period: {name} les noms de champ ne peuvent pas contenir de point : {name}
two cells would both be named {name} deux cellules porteraient le nom {name}
a field named {name} already exists un champ nommé {name} existe déjà
Can't render this file because it contains an unexpected character in line 6 and column 39.
+20
View File
@@ -2480,3 +2480,23 @@ animation effects Off Ki
Paragraph spacing Bekezdéstávolság
Less Kevesebb
Other options Egyéb beállítások
# Prepare a form ▸ Text field table
Add text field table Szövegmező-táblázat hozzáadása
Text field table Szövegmező-táblázat
Text Field Table Szövegmező-táblázat
Columns Oszlopok
Row spacing Sorköz
Column spacing Oszlopköz
First row number Első sor száma
Column widths Oszlopszélességek
Distribute evenly Egyenletes elosztás
Field names Mezőnevek
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. Az N vagy a {row} a sor száma, a {col} az oszlopé, a {seq} a cellaé. Ha egyik sincs, a sor száma a végére kerül.
Column {n} {n}. oszlop
Add fields Mezők hozzáadása
the rows are too short: make the area taller, or use fewer rows or less row spacing a sorok túl alacsonyak: növeld a terület magasságát, vagy használj kevesebb sort vagy kisebb sorközt
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing egy oszlop túl keskeny: szélesítsd a területet, adj több helyet az oszlopnak, vagy használj kevesebb oszlopot vagy kisebb oszlopközt
field names can't contain a period: {name} a mezőnevek nem tartalmazhatnak pontot: {name}
two cells would both be named {name} két cella neve is {name} lenne
a field named {name} already exists már van {name} nevű mező
Can't render this file because it contains an unexpected character in line 7 and column 44.
+24
View File
@@ -2693,3 +2693,27 @@ certificate SHA-256 digest Impronta SHA-256
Paragraph spacing Spaziatura paragrafi
Less Meno
Other options Altre opzioni
# Prepare a form ▸ Text field table
Add text field table Aggiungi tabella di campi di testo
Text field table Tabella di campi di testo
Text Field Table Tabella di campi di testo
Columns Colonne
Row spacing Spaziatura tra le righe
Column spacing Spaziatura tra le colonne
First row number Numero della prima riga
Column widths Larghezze delle colonne
Distribute evenly Distribuisci uniformemente
Field names Nomi dei campi
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N o {row} è il numero di riga, {col} quello di colonna, {seq} quello di cella. Senza nessuno di essi, il numero di riga va alla fine.
Column {n} Colonna {n}
Add fields Aggiungi campi
a table has 1 to {max} rows una tabella ha da 1 a {max} righe
a table has 1 to {max} columns una tabella ha da 1 a {max} colonne
the first row number can be at most {max} il numero della prima riga può essere al massimo {max}
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 l'area, la spaziatura e le larghezze delle colonne devono essere numeri, la spaziatura 0 o più e ogni larghezza maggiore di 0
the rows are too short: make the area taller, or use fewer rows or less row spacing le righe sono troppo basse: rendi l'area più alta oppure usa meno righe o meno spaziatura tra le righe
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing una colonna è troppo stretta: rendi l'area più larga, dai più spazio alla colonna oppure usa meno colonne o meno spaziatura tra le colonne
field names can't contain a period: {name} i nomi dei campi non possono contenere un punto: {name}
two cells would both be named {name} due celle si chiamerebbero entrambe {name}
a field named {name} already exists esiste già un campo chiamato {name}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20
View File
@@ -2481,3 +2481,23 @@ animation effects Off オフ
Paragraph spacing 段落間隔
Less 折りたたむ
Other options その他のオプション
# Prepare a form ▸ Text field table
Add text field table テキストフィールドの表を追加
Text field table テキストフィールドの表
Text Field Table テキストフィールドの表
Columns 列
Row spacing 行の間隔
Column spacing 列の間隔
First row number 最初の行番号
Column widths 列の幅
Distribute evenly 均等に配分
Field names フィールド名
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N または {row} は行番号、{col} は列番号、{seq} はセル番号です。どれもない場合は、行番号が末尾に付きます。
Column {n} 列 {n}
Add fields フィールドを追加
the rows are too short: make the area taller, or use fewer rows or less row spacing 行の高さが足りません: 範囲を高くするか、行数または行の間隔を減らしてください
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing 列の幅が足りません: 範囲を広げるか、その列の幅を増やすか、列数または列の間隔を減らしてください
field names can't contain a period: {name} フィールド名にピリオドは使えません: {name}
two cells would both be named {name} 2 つのセルが同じ名前 {name} になります
a field named {name} already exists {name} という名前のフィールドは既にあります
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20
View File
@@ -2479,3 +2479,23 @@ animation effects Off Өшірулі
Paragraph spacing Абзац аралығы
Less Азырақ
Other options Басқа параметрлер
# Prepare a form ▸ Text field table
Add text field table Мәтін өрістерінің кестесін қосу
Text field table Мәтін өрістерінің кестесі
Text Field Table Мәтін өрістерінің кестесі
Columns Бағандар
Row spacing Жолдар аралығы
Column spacing Бағандар аралығы
First row number Бірінші жол нөмірі
Column widths Баған ендері
Distribute evenly Біркелкі бөлу
Field names Өріс атаулары
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N немесе {row} — жол нөмірі, {col} — баған нөмірі, {seq} — ұяшық нөмірі. Олардың ешқайсысы болмаса, жол нөмірі соңына қосылады.
Column {n} {n}-баған
Add fields Өрістерді қосу
the rows are too short: make the area taller, or use fewer rows or less row spacing жолдар тым аласа: аймақты биіктетіңіз немесе жолдар санын не жолдар аралығын азайтыңыз
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing баған тым тар: аймақты кеңейтіңіз, бағанға көбірек ен беріңіз немесе бағандар санын не бағандар аралығын азайтыңыз
field names can't contain a period: {name} өріс атауларында нүкте болмауы керек: {name}
two cells would both be named {name} екі ұяшықтың да атауы {name} болар еді
a field named {name} already exists {name} атты өріс бар
Can't render this file because it contains an unexpected character in line 856 and column 9.
+24
View File
@@ -2731,3 +2731,27 @@ theme Appearance Išvaizda
Paragraph spacing Tarpas tarp pastraipų
Less Mažiau
Other options Kitos parinktys
# Prepare a form ▸ Text field table
Add text field table Pridėti teksto laukų lentelę
Text field table Teksto laukų lentelė
Text Field Table Teksto laukų lentelė
Columns Stulpeliai
Row spacing Tarpas tarp eilučių
Column spacing Tarpas tarp stulpelių
First row number Pirmosios eilutės numeris
Column widths Stulpelių pločiai
Distribute evenly Paskirstyti tolygiai
Field names Laukų pavadinimai
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N arba {row} – eilutės numeris, {col} – stulpelio numeris, {seq} – langelio numeris. Jei nė vieno nėra, eilutės numeris pridedamas gale.
Column {n} Stulpelis {n}
Add fields Pridėti laukus
the rows are too short: make the area taller, or use fewer rows or less row spacing eilutės per žemos: padidinkite srities aukštį arba naudokite mažiau eilučių ar mažesnį tarpą tarp jų
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing stulpelis per siauras: praplatinkite sritį, skirkite stulpeliui daugiau pločio arba naudokite mažiau stulpelių ar mažesnį tarpą tarp jų
field names can't contain a period: {name} laukų pavadinimuose negali būti taško: {name}
two cells would both be named {name} du langeliai būtų pavadinti {name}
a field named {name} already exists laukas pavadinimu {name} jau yra
a table has 1 to {max} rows lentelėje gali būti nuo 1 iki {max} eilučių
a table has 1 to {max} columns lentelėje gali būti nuo 1 iki {max} stulpelių
the first row number can be at most {max} pirmosios eilutės numeris gali būti ne didesnis nei {max}
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 sritis, tarpai ir stulpelių pločiai turi būti skaičiai, tarpai – 0 arba daugiau, o kiekvienas plotis – daugiau nei 0
Can't render this file because it contains an unexpected character in line 404 and column 9.
+5 -3
View File
@@ -1290,7 +1290,7 @@ mod tests {
/// Spanish says everything the layers below word for themselves: what the accessibility
/// checker finds and what its saved report calls things, what the PDF/A verifier reports, what
/// a signature's details say and the changes they name, what can be wrong with the pages to
/// print, and what an edit is called in the history.
/// print or with a table of text fields, and what an edit is called in the history.
///
/// These lists are the whole point of the engine handing the wording over apart from the
/// document's own words: a catalog that covers them says those sentences in its own language.
@@ -1298,7 +1298,7 @@ mod tests {
#[test]
fn spanish_covers_what_the_engine_says() {
let es = Lang::from_code("es").expect("es registered");
let lists: [(&str, &[&str]); 8] = [
let lists: [(&str, &[&str]); 9] = [
("accessibility finding", pdfcraft_engine::a11y::WORDINGS),
("accessibility report", pdfcraft_engine::a11y::REPORT_WORDS),
("PDF/A problem", pdfcraft_engine::pdfa::WORDINGS),
@@ -1307,6 +1307,7 @@ mod tests {
("pages to print", pdfcraft_engine::print::WORDINGS),
("edit label", pdfcraft_engine::EDIT_LABELS),
("batch label", pdfcraft_engine::BATCH_LABELS),
("text field table mistake", pdfcraft_engine::field_table::WORDINGS),
];
for (what, list) in lists {
assert!(!list.is_empty(), "no {what} wordings to cover");
@@ -1322,11 +1323,12 @@ mod tests {
#[test]
fn spanish_keeps_the_holes_in_what_the_engine_says() {
let es = Lang::from_code("es").expect("es registered");
let lists: [&[&str]; 4] = [
let lists: [&[&str]; 5] = [
pdfcraft_engine::a11y::WORDINGS,
pdfcraft_engine::pdfa::WORDINGS,
pdfcraft_engine::sign::pdf::WORDINGS,
pdfcraft_engine::print::WORDINGS,
pdfcraft_engine::field_table::WORDINGS,
];
for wording in lists.into_iter().flatten() {
let said = tr(es, wording);
+20
View File
@@ -2457,3 +2457,23 @@ animation effects Off Wyłączone
Paragraph spacing Odstęp między akapitami
Less Mniej
Other options Inne opcje
# Prepare a form ▸ Text field table
Add text field table Dodaj tabelę pól tekstowych
Text field table Tabela pól tekstowych
Text Field Table Tabela pól tekstowych
Columns Kolumny
Row spacing Odstęp między wierszami
Column spacing Odstęp między kolumnami
First row number Numer pierwszego wiersza
Column widths Szerokości kolumn
Distribute evenly Rozłóż równomiernie
Field names Nazwy pól
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N lub {row} to numer wiersza, {col} numer kolumny, a {seq} numer komórki. Bez żadnego z nich numer wiersza trafia na koniec.
Column {n} Kolumna {n}
Add fields Dodaj pola
the rows are too short: make the area taller, or use fewer rows or less row spacing wiersze są za niskie: powiększ obszar w pionie albo użyj mniej wierszy lub mniejszego odstępu między wierszami
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing kolumna jest za wąska: poszerz obszar, daj kolumnie więcej miejsca albo użyj mniej kolumn lub mniejszego odstępu między kolumnami
field names can't contain a period: {name} nazwy pól nie mogą zawierać kropki: {name}
two cells would both be named {name} dwie komórki miałyby nazwę {name}
a field named {name} already exists pole o nazwie {name} już istnieje
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20
View File
@@ -2495,3 +2495,23 @@ animation effects Off Desativados
Paragraph spacing Espaçamento entre parágrafos
Less Menos
Other options Outras opções
# Prepare a form ▸ Text field table
Add text field table Adicionar tabela de campos de texto
Text field table Tabela de campos de texto
Text Field Table Tabela de campos de texto
Columns Colunas
Row spacing Espaçamento entre linhas
Column spacing Espaçamento entre colunas
First row number Número da primeira linha
Column widths Larguras das colunas
Distribute evenly Distribuir igualmente
Field names Nomes dos campos
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N ou {row} é o número da linha, {col} o da coluna e {seq} o da célula. Sem nenhum deles, o número da linha vai no final.
Column {n} Coluna {n}
Add fields Adicionar campos
the rows are too short: make the area taller, or use fewer rows or less row spacing as linhas estão baixas demais: deixe a área mais alta ou use menos linhas ou menos espaçamento entre linhas
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing uma coluna está estreita demais: deixe a área mais larga, dê mais largura à coluna ou use menos colunas ou menos espaçamento entre colunas
field names can't contain a period: {name} nomes de campo não podem conter ponto: {name}
two cells would both be named {name} duas células teriam o nome {name}
a field named {name} already exists já existe um campo chamado {name}
Can't render this file because it contains an unexpected character in line 9 and column 16.
+20
View File
@@ -2394,3 +2394,23 @@ animation effects Off Выкл
Paragraph spacing Интервал между абзацами
Less Меньше
Other options Другие параметры
# Prepare a form ▸ Text field table
Add text field table Добавить таблицу текстовых полей
Text field table Таблица текстовых полей
Text Field Table Таблица текстовых полей
Columns Столбцы
Row spacing Интервал между строками
Column spacing Интервал между столбцами
First row number Номер первой строки
Column widths Ширина столбцов
Distribute evenly Распределить равномерно
Field names Имена полей
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N или {row} — номер строки, {col} — номер столбца, {seq} — номер ячейки. Если их нет, номер строки добавляется в конец.
Column {n} Столбец {n}
Add fields Добавить поля
the rows are too short: make the area taller, or use fewer rows or less row spacing строки слишком низкие: увеличьте высоту области, уменьшите число строк или интервал между ними
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing столбец слишком узкий: расширьте область, отдайте столбцу больше ширины, уменьшите число столбцов или интервал между ними
field names can't contain a period: {name} имена полей не могут содержать точку: {name}
two cells would both be named {name} две ячейки получили бы одно имя {name}
a field named {name} already exists поле с именем {name} уже существует
Can't render this file because it contains an unexpected character in line 9 and column 16.
+20
View File
@@ -2486,3 +2486,23 @@ animation effects Off Izklopljeni
Paragraph spacing Razmik med odstavki
Less Manj
Other options Druge možnosti
# Prepare a form ▸ Text field table
Add text field table Dodaj tabelo besedilnih polj
Text field table Tabela besedilnih polj
Text Field Table Tabela besedilnih polj
Columns Stolpci
Row spacing Razmik med vrsticami
Column spacing Razmik med stolpci
First row number Številka prve vrstice
Column widths Širine stolpcev
Distribute evenly Enakomerno porazdeli
Field names Imena polj
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N ali {row} je številka vrstice, {col} številka stolpca in {seq} številka celice. Brez njih se številka vrstice doda na konec.
Column {n} Stolpec {n}
Add fields Dodaj polja
the rows are too short: make the area taller, or use fewer rows or less row spacing vrstice so prenizke: povečajte višino območja ali uporabite manj vrstic ali manjši razmik med vrsticami
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing stolpec je preozek: razširite območje, stolpcu dajte več širine ali uporabite manj stolpcev ali manjši razmik med stolpci
field names can't contain a period: {name} imena polj ne smejo vsebovati pike: {name}
two cells would both be named {name} dve celici bi imeli enako ime {name}
a field named {name} already exists polje z imenom {name} že obstaja
Can't render this file because it contains an unexpected character in line 9 and column 16.
+20
View File
@@ -2390,3 +2390,23 @@ animation effects Off ఆఫ్
Paragraph spacing పేరా అంతరం
Less తక్కువ
Other options ఇతర ఎంపికలు
# Prepare a form ▸ Text field table
Add text field table టెక్స్ట్ ఫీల్డ్ పట్టిక జోడించు
Text field table టెక్స్ట్ ఫీల్డ్ పట్టిక
Text Field Table టెక్స్ట్ ఫీల్డ్ పట్టిక
Columns నిలువు వరుసలు
Row spacing వరుసల మధ్య దూరం
Column spacing నిలువు వరుసల మధ్య దూరం
First row number మొదటి వరుస సంఖ్య
Column widths నిలువు వరుసల వెడల్పులు
Distribute evenly సమానంగా పంచు
Field names ఫీల్డ్ పేర్లు
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N లేదా {row} వరుస సంఖ్య, {col} నిలువు వరుస సంఖ్య, {seq} గడి సంఖ్య. వీటిలో ఏదీ లేకపోతే, వరుస సంఖ్య చివర చేరుతుంది.
Column {n} నిలువు వరుస {n}
Add fields ఫీల్డ్‌లు జోడించు
the rows are too short: make the area taller, or use fewer rows or less row spacing వరుసలు చాలా తక్కువ ఎత్తులో ఉన్నాయి: ప్రాంతాన్ని ఎత్తుగా చేయండి, లేదా తక్కువ వరుసలు లేదా తక్కువ వరుస దూరం వాడండి
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing ఒక నిలువు వరుస చాలా ఇరుకుగా ఉంది: ప్రాంతాన్ని వెడల్పు చేయండి, ఆ నిలువు వరుసకు ఎక్కువ వెడల్పు ఇవ్వండి, లేదా తక్కువ నిలువు వరుసలు లేదా తక్కువ దూరం వాడండి
field names can't contain a period: {name} ఫీల్డ్ పేర్లలో చుక్క ఉండకూడదు: {name}
two cells would both be named {name} రెండు గడులకూ {name} అనే పేరు వస్తుంది
a field named {name} already exists {name} అనే ఫీల్డ్ ఇప్పటికే ఉంది
Can't render this file because it contains an unexpected character in line 13 and column 16.
+20
View File
@@ -2477,3 +2477,23 @@ animation effects Off Kapalı
Paragraph spacing Paragraf aralığı
Less Daha az
Other options Diğer seçenekler
# Prepare a form ▸ Text field table
Add text field table Metin alanı tablosu ekle
Text field table Metin alanı tablosu
Text Field Table Metin alanı tablosu
Columns Sütunlar
Row spacing Satır aralığı
Column spacing Sütun aralığı
First row number İlk satır numarası
Column widths Sütun genişlikleri
Distribute evenly Eşit dağıt
Field names Alan adları
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N veya {row} satır numarası, {col} sütun numarası, {seq} hücre numarasıdır. Hiçbiri yoksa satır numarası sona eklenir.
Column {n} Sütun {n}
Add fields Alanları ekle
the rows are too short: make the area taller, or use fewer rows or less row spacing satırlar çok kısa: alanı uzatın ya da daha az satır veya daha az satır aralığı kullanın
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing bir sütun çok dar: alanı genişletin, sütuna daha fazla yer verin ya da daha az sütun veya daha az sütun aralığı kullanın
field names can't contain a period: {name} alan adları nokta içeremez: {name}
two cells would both be named {name} iki hücrenin de adı {name} olurdu
a field named {name} already exists {name} adlı bir alan zaten var
Can't render this file because it contains an unexpected character in line 855 and column 9.
+20
View File
@@ -2503,3 +2503,23 @@ animation effects Off Вимкнено
Paragraph spacing Інтервал між абзацами
Less Менше
Other options Інші параметри
# Prepare a form ▸ Text field table
Add text field table Додати таблицю текстових полів
Text field table Таблиця текстових полів
Text Field Table Таблиця текстових полів
Columns Стовпці
Row spacing Інтервал між рядками
Column spacing Інтервал між стовпцями
First row number Номер першого рядка
Column widths Ширина стовпців
Distribute evenly Розподілити рівномірно
Field names Імена полів
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N або {row} — номер рядка, {col} — номер стовпця, {seq} — номер клітинки. Якщо їх немає, номер рядка додається в кінці.
Column {n} Стовпець {n}
Add fields Додати поля
the rows are too short: make the area taller, or use fewer rows or less row spacing рядки занадто низькі: збільште висоту області, зменште кількість рядків або інтервал між ними
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing стовпець занадто вузький: розширте область, дайте стовпцю більше ширини, зменште кількість стовпців або інтервал між ними
field names can't contain a period: {name} імена полів не можуть містити крапку: {name}
two cells would both be named {name} дві клітинки мали б однакове ім'я {name}
a field named {name} already exists поле з іменем {name} уже існує
Can't render this file because it contains an unexpected character in line 6 and column 47.
+24
View File
@@ -2733,3 +2733,27 @@ key Space Space
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
# Prepare a form ▸ Text field table
Add text field table Thêm bảng trường văn bản
Text field table Bảng trường văn bản
Text Field Table Bảng trường văn bản
Columns Cột
Row spacing Khoảng cách hàng
Column spacing Khoảng cách cột
First row number Số hàng đầu tiên
Column widths Độ rộng cột
Distribute evenly Phân bố đều
Field names Tên trường
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N hoặc {row} là số hàng, {col} là số cột, {seq} là số ô. Nếu không có cái nào, số hàng được thêm vào cuối.
Column {n} Cột {n}
Add fields Thêm các trường
the rows are too short: make the area taller, or use fewer rows or less row spacing các hàng quá thấp: hãy làm vùng cao hơn, hoặc dùng ít hàng hơn hay khoảng cách hàng nhỏ hơn
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing một cột quá hẹp: hãy làm vùng rộng hơn, cho cột thêm độ rộng, hoặc dùng ít cột hơn hay khoảng cách cột nhỏ hơn
field names can't contain a period: {name} tên trường không được chứa dấu chấm: {name}
two cells would both be named {name} hai ô sẽ cùng có tên {name}
a field named {name} already exists đã có trường tên {name}
a table has 1 to {max} rows một bảng có từ 1 đến {max} hàng
a table has 1 to {max} columns một bảng có từ 1 đến {max} cột
the first row number can be at most {max} số hàng đầu tiên tối đa là {max}
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 vùng, khoảng cách và độ rộng cột phải là số, khoảng cách từ 0 trở lên và mỗi độ rộng lớn hơn 0
Can't render this file because it contains an unexpected character in line 491 and column 12.
+20
View File
@@ -2445,3 +2445,23 @@ animation effects Off 关
Paragraph spacing 段落间距
Less 收起
Other options 其他选项
# Prepare a form ▸ Text field table
Add text field table 添加文本字段表格
Text field table 文本字段表格
Text Field Table 文本字段表格
Columns 列
Row spacing 行间距
Column spacing 列间距
First row number 起始行号
Column widths 列宽
Distribute evenly 平均分布
Field names 字段名称
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N 或 {row} 表示行号,{col} 表示列号,{seq} 表示单元格序号。如果都没有,行号加在末尾。
Column {n} 第 {n} 列
Add fields 添加字段
the rows are too short: make the area taller, or use fewer rows or less row spacing 行太矮:请增加区域高度,或减少行数或行间距
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing 某列太窄:请加宽区域、给该列更多宽度,或减少列数或列间距
field names can't contain a period: {name} 字段名称不能包含句点:{name}
two cells would both be named {name} 两个单元格的名称都会是 {name}
a field named {name} already exists 已存在名为 {name} 的字段
Can't render this file because it contains an unexpected character in line 9 and column 16.
+20
View File
@@ -2431,3 +2431,23 @@ animation effects Off 關
Paragraph spacing 段落間距
Less 收起
Other options 其他選項
# Prepare a form ▸ Text field table
Add text field table 新增文字欄位表格
Text field table 文字欄位表格
Text Field Table 文字欄位表格
Columns 欄
Row spacing 列間距
Column spacing 欄間距
First row number 起始列號
Column widths 欄寬
Distribute evenly 平均分配
Field names 欄位名稱
N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N 或 {row} 代表列號,{col} 代表欄號,{seq} 代表儲存格序號。若都沒有,列號會加在結尾。
Column {n} 第 {n} 欄
Add fields 新增欄位
the rows are too short: make the area taller, or use fewer rows or less row spacing 列太矮:請加高區域,或減少列數或列間距
a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing 某欄太窄:請加寬區域、給該欄更多寬度,或減少欄數或欄間距
field names can't contain a period: {name} 欄位名稱不能包含句點:{name}
two cells would both be named {name} 兩個儲存格的名稱都會是 {name}
a field named {name} already exists 已有名為 {name} 的欄位
Can't render this file because it contains an unexpected character in line 9 and column 16.
+6
View File
@@ -80,6 +80,7 @@ mod bidi;
pub mod bulk_fields;
mod dialogs;
mod edit_text_ui;
pub mod field_table_ui;
pub use edit_text_ui::CropMode;
mod editing;
#[cfg(not(target_arch = "wasm32"))]
@@ -334,6 +335,8 @@ pub enum Dialog {
Optimize,
/// Prepare a form ▸ right-click a field ▸ Duplicate.
DuplicateField,
/// Prepare a form ▸ Text field table: a movable window rather than a modal (`field_table_ui`).
FieldTable,
/// Check for accessibility ▸ Accessibility Checker Options.
AccessibilityOptions,
/// Scan & OCR ▸ Recognize text.
@@ -736,6 +739,8 @@ pub struct PdfCraftApp {
pub attach_override: Option<(String, Vec<u8>)>,
/// Duplicate Field: which field and onto which pages.
pub duplicate_draft: Option<DuplicateDraft>,
/// Text field table: the table being set up, and the page it goes on.
pub field_table_draft: Option<field_table_ui::FieldTableDraft>,
/// Prepare a form ▸ Preview: fill the form instead of editing its fields.
pub form_preview: bool,
/// Where autosaves go (`None`: autosave off, e.g. on the web and in tests).
@@ -1036,6 +1041,7 @@ impl PdfCraftApp {
system_clipboard: true,
attach_override: None,
duplicate_draft: None,
field_table_draft: None,
form_preview: false,
window_title: String::new(),
recovery: None,
+40 -4
View File
@@ -17,12 +17,14 @@ use crate::widgets;
const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
/// Acrobat's field fill in Prepare Form (light blue).
const FIELD_FILL: Color32 = Color32::from_rgba_premultiplied(0x1B, 0x34, 0x54, 0x59);
pub(crate) const FIELD_FILL: Color32 = Color32::from_rgba_premultiplied(0x1B, 0x34, 0x54, 0x59);
/// The Add form components tools.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FieldTool {
Text,
/// Rows and columns of text fields in the area drawn, set up in a window first.
TextTable,
CheckBox,
Radio,
Combo,
@@ -33,8 +35,9 @@ pub enum FieldTool {
Signature,
}
pub const FIELD_TOOLS: [FieldTool; 9] = [
pub const FIELD_TOOLS: [FieldTool; 10] = [
FieldTool::Text,
FieldTool::TextTable,
FieldTool::CheckBox,
FieldTool::Radio,
FieldTool::Combo,
@@ -49,6 +52,7 @@ impl FieldTool {
pub fn command(self) -> &'static str {
match self {
FieldTool::Text => "form.add.text",
FieldTool::TextTable => "form.add.text_table",
FieldTool::CheckBox => "form.add.checkbox",
FieldTool::Radio => "form.add.radio",
FieldTool::Combo => "form.add.combo",
@@ -67,6 +71,7 @@ impl FieldTool {
pub fn label(self) -> &'static str {
match self {
FieldTool::Text => "Text field",
FieldTool::TextTable => "Text field table",
FieldTool::CheckBox => "Checkbox",
FieldTool::Radio => "Radio button",
FieldTool::Combo => "Drop-down list",
@@ -82,7 +87,8 @@ impl FieldTool {
/// join it with the next free choice.
pub fn new_field(self, join: Option<&FormField>) -> NewField {
match self {
FieldTool::Text => NewField::Text { multiline: false },
// A table's cells are text fields.
FieldTool::Text | FieldTool::TextTable => NewField::Text { multiline: false },
FieldTool::CheckBox => NewField::CheckBox,
FieldTool::Radio => match join.filter(|f| f.kind == FormFieldKind::Radio) {
Some(g) => {
@@ -105,6 +111,7 @@ impl FieldTool {
/// Width and height in points of a field placed with a single click.
pub fn default_size(self) -> (f64, f64) {
match self {
FieldTool::TextTable => (180.0, 110.0),
FieldTool::CheckBox | FieldTool::Radio => (14.0, 14.0),
FieldTool::List => (144.0, 54.0),
FieldTool::Button => (72.0, 22.0),
@@ -178,6 +185,23 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
[u[0] as f64, u[1] as f64]
}
/// Where a table goes, in view space, for a screen rect drawn with the table tool: a click (a rect
/// under 4 pixels either way) puts the default size's top-left corner at the pointer, moved in from
/// the page's edges as far as it takes to stay on the page.
fn table_rect(xf: &PageXform, r: Rect) -> [f64; 4] {
let (x0, y0) = xf.screen_to_view(r.min);
if r.width() < 4.0 || r.height() < 4.0 {
let (pw, ph) = (f64::from(xf.pw), f64::from(xf.ph));
let (dw, dh) = FieldTool::TextTable.default_size();
// Smaller than the default only on a page smaller than it.
let (w, h) = (dw.min(pw), dh.min(ph));
let (x, y) = (f64::from(x0).min(pw - w).max(0.0), f64::from(y0).min(ph - h).max(0.0));
return [x, y, x + w, y + h];
}
let (x1, y1) = xf.screen_to_view(r.max);
[x0, y0, x1, y1].map(f64::from)
}
/// A screen rect → a normalised user-space rect.
fn user_rect(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] {
let (a, b) = (to_user(xf, info, page, r.min), to_user(xf, info, page, r.max));
@@ -208,6 +232,8 @@ pub(crate) struct Outcome {
pub properties: bool,
/// A field was placed: go back to the Select tool.
pub placed: bool,
/// A table of text fields was drawn: open its window for this page and area (view space).
pub table: Option<(usize, [f64; 4])>,
}
/// Pointer input on one page while preparing a form.
@@ -223,7 +249,7 @@ pub(crate) fn page_input(
allowed: bool,
view: &mut DocView,
) -> Outcome {
let mut out = Outcome { consumed: false, properties: false, placed: false };
let mut out = Outcome { consumed: false, properties: false, placed: false, table: None };
let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos()));
let prep = &mut view.prepare;
@@ -267,6 +293,11 @@ pub(crate) fn page_input(
}
Grab::Draw(from) => {
let Some(tool) = tool else { return out };
if tool == FieldTool::TextTable {
out.table = Some((page, table_rect(xf, Rect::from_two_pos(from, end).intersect(xf.rect))));
out.placed = true;
return out;
}
let r = Rect::from_two_pos(from, end).intersect(xf.rect);
let rect = user_rect(xf, info, page, r);
let rect = if r.width() < 4.0 || r.height() < 4.0 {
@@ -312,6 +343,11 @@ pub(crate) fn page_input(
if resp.clicked() {
// A click without a drag: finish at once.
prep.grab = None;
if tool == FieldTool::TextTable {
out.table = Some((page, table_rect(xf, Rect::from_min_size(p, egui::Vec2::ZERO))));
out.placed = true;
return out;
}
let at = to_user(xf, info, page, p);
let (w, h) = tool.default_size();
let join = prep.selected.as_ref().and_then(|(n, _)| form.iter().find(|f| &f.name == n));
+231 -2
View File
@@ -2,9 +2,11 @@
//! Field Properties and deleting.
use egui::Pos2;
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool};
use egui_kittest::kittest::{Queryable, by};
use pdfcraft_engine::field_table::TableColumn;
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool};
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|cc| {
@@ -656,6 +658,233 @@ fn actions_tab_adds_and_removes_actions() {
);
}
/// Text field table: pick the tool and drag out an area in the empty lower part of page 1, 200 by
/// 120 points from (40, 240) as displayed. The table's window opens for it.
fn draw_table(h: &mut Harness<'static, PdfCraftApp>) {
assert!(h.state_mut().execute("form.add.text_table"));
h.run_steps(2);
let (a, b) = (at(h, 40.0, 240.0), at(h, 240.0, 360.0));
drag(h, a, b);
assert_eq!(h.state().dialog, Some(Dialog::FieldTable));
}
fn columns(patterns: &[(&str, f64)]) -> Vec<TableColumn> {
patterns.iter().map(|(p, w)| TableColumn { pattern: (*p).to_owned(), width: *w }).collect()
}
#[test]
fn a_text_field_table_is_drawn_set_up_and_added_in_one_step() {
let mut h = harness();
let before = names(&h);
draw_table(&mut h);
assert_eq!(h.state().quick_tool, QuickTool::Select, "back to Select once the area is drawn");
{
let s = h.state_mut();
let id = s.views[0].id;
let d = s.field_table_draft.as_mut().expect("the table's window is open");
assert_eq!((d.doc, d.page), (id, 0));
let r = d.table.rect;
assert!([40.0, 240.0, 240.0, 360.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "drawn as displayed: {r:?}");
d.table.rows = 2;
d.table.columns = columns(&[("desc_N", 3.0), ("price_N", 1.0)]);
}
h.run_steps(2);
h.get_by_label("Add fields").click();
h.run_steps(3);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false), "added, the window closes");
let added: Vec<_> = names(&h).into_iter().filter(|n| !before.contains(n)).collect();
assert_eq!(added, ["desc_1", "price_1", "desc_2", "price_2"]);
// 150 and 50 points wide, 60 high, the first row at the top: user space has y up from 400.
let r = rect_of(&h, "desc_1");
assert!([40.0, 100.0, 190.0, 160.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "{r:?}");
let r = rect_of(&h, "price_2");
assert!([190.0, 40.0, 240.0, 100.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "{r:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Add text field table"));
h.state_mut().execute("edit.undo");
h.run_steps(2);
assert_eq!(names(&h), before, "one undo takes the whole table back");
}
#[test]
fn a_table_whose_names_are_taken_stays_open_saying_so() {
let mut h = harness();
draw_table(&mut h);
h.get_by_label("Add fields").click();
h.run_steps(3);
assert!(names(&h).contains(&"Table1_4".to_owned()), "{:?}", names(&h));
// The next table takes the next free names on its own.
draw_table(&mut h);
assert_eq!(h.state().field_table_draft.as_ref().unwrap().table.columns[0].pattern, "Table2_N");
// Named like the first, it can't be added: the window says why and keeps what was set up.
h.state_mut().field_table_draft.as_mut().unwrap().table.columns[0].pattern = "Table1_N".into();
h.run_steps(2);
let count = names(&h).len();
h.get_by_label("a field named \"Table1_1\" already exists");
h.get_by_label("Add fields").click();
h.run_steps(3);
assert_eq!(names(&h).len(), count, "nothing added");
assert_eq!(h.state().dialog, Some(Dialog::FieldTable), "still open");
// Escape closes it, adding nothing.
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some(), names(&h).len()), (None, false, count));
}
/// Enter commits a number being typed. It used to add the table there and then, before the rest
/// of it was set up.
#[test]
fn enter_in_a_number_field_does_not_add_the_table() {
let mut h = harness();
let before = names(&h).len();
draw_table(&mut h);
let rows = h.query_all(by().role(Role::SpinButton)).next().map(|n| n.rect()).expect("the Rows field");
h.hover_at(rows.center());
h.drag_at(rows.center());
h.run_steps(1);
h.drop_at(rows.center());
h.run_steps(2);
h.key_press(egui::Key::Backspace);
h.event(egui::Event::Text("6".into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(names(&h).len(), before, "nothing added");
assert_eq!(h.state().field_table_draft.as_ref().map(|d| d.table.rows), Some(6), "the number is in, the window open");
}
/// With 26 or more columns each pair of columns has less than twice the least share a divider
/// keeps, and dragging one panicked in `f64::clamp`.
#[test]
fn dragging_a_column_divider_between_many_columns() {
let mut h = harness();
draw_table(&mut h);
h.state_mut().field_table_draft.as_mut().unwrap().table.columns = columns(&[("c{c}_N", 1.0); 30]);
h.run_steps(2);
let (above, below) = (h.get_by_label("Distribute evenly").rect(), h.get_by_label("Field names").rect());
let p = egui::pos2(above.center().x - 120.0, (above.bottom() + below.top()) / 2.0);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
for dx in [-10.0, -20.0] {
h.hover_at(p + egui::vec2(dx, 0.0));
h.run_steps(1);
}
h.drop_at(p + egui::vec2(-20.0, 0.0));
h.run_steps(2);
let widths: Vec<f64> = h.state().field_table_draft.as_ref().unwrap().table.columns.iter().map(|c| c.width).collect();
assert!(widths.iter().any(|w| (w - 1.0).abs() > 1e-9), "a divider moved: {widths:?}");
assert!((widths.iter().sum::<f64>() - 30.0).abs() < 1e-9 && widths.iter().all(|w| *w > 0.0), "{widths:?}");
}
#[test]
fn the_table_window_closes_when_its_document_is_left() {
let mut h = harness();
let blank = pdfcraft_engine::Session::new().create_blank(300.0, 400.0, 1).unwrap().to_vec();
h.state_mut().open_bytes("other.pdf", None, blank).unwrap();
h.state_mut().active = Some(0);
h.run_steps(4);
let before = names(&h).len();
draw_table(&mut h);
// The other document's tab is still in reach of the pointer: choosing it closes the window.
let tab = h.get_all_by_label("other.pdf").next().map(|n| n.rect()).expect("its tab");
h.hover_at(tab.center());
h.run_steps(1);
h.drag_at(tab.center());
h.run_steps(1);
h.drop_at(tab.center());
h.run_steps(3);
assert_eq!(h.state().active, Some(1));
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false));
h.state_mut().active = Some(0);
h.run_steps(2);
assert_eq!(names(&h).len(), before, "nothing added to either document");
// So does leaving Prepare a form.
draw_table(&mut h);
h.state_mut().left_open = false;
h.run_steps(2);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false));
}
/// With its panel closed after the tool was picked, the form is still prepared and a table can be
/// drawn: its window opens Prepare a form again, which it belongs to. Drawing sends the tool back
/// to Select, so the window used to close itself at once, nothing being prepared any more.
#[test]
fn a_table_drawn_with_the_panel_closed_opens_it_again() {
let mut h = harness();
let before = names(&h).len();
assert!(h.state_mut().execute("form.add.text_table"));
h.run_steps(2);
h.state_mut().left_open = false;
h.run_steps(2);
assert!(h.state().is_preparing(), "the picked tool keeps the form prepared");
let (a, b) = (at(&h, 40.0, 240.0), at(&h, 240.0, 360.0));
drag(&mut h, a, b);
h.run_steps(3);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (Some(Dialog::FieldTable), true), "the window stays open");
assert!(h.state().left_open && h.state().is_preparing(), "Prepare a form is open again");
h.get_by_label("Add fields").click();
h.run_steps(3);
assert!(names(&h).len() > before, "the table is added");
}
/// The window belongs to the page as it was when it opened: rotated (or moved, deleted, undone)
/// under it, the page would put the area the table was set up for somewhere else.
#[test]
fn the_table_window_closes_when_the_document_changes_under_it() {
let mut h = harness();
let before = names(&h);
draw_table(&mut h);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }));
h.run_steps(2);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false), "closed by the rotation");
assert_eq!(names(&h), before, "nothing added");
}
/// Escape in a name field leaves the field, as it does in any field; it closed the window too,
/// and the column setup with it.
#[test]
fn escape_in_a_name_field_leaves_the_field_not_the_table() {
let mut h = harness();
draw_table(&mut h);
// The app's own text boxes (zoom, page number) are inputs too: take the one in the window.
let window = h.get_by_label("Text Field Table").rect();
let field =
h.query_all(by().role(Role::TextInput)).map(|n| n.rect()).find(|r| window.contains(r.center())).expect("the first column's name field");
h.hover_at(field.center());
h.drag_at(field.center());
h.run_steps(1);
h.drop_at(field.center());
h.run_steps(2);
h.event(egui::Event::Text("x".into()));
h.run_steps(1);
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert_eq!(h.state().dialog, Some(Dialog::FieldTable), "still open");
assert_eq!(h.state().field_table_draft.as_ref().map(|d| d.table.columns[0].pattern.as_str()), Some("Table1_Nx"), "what was typed stays");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false), "a second Escape closes it");
}
/// A click puts the default 180 × 110 table's corner at the pointer; near the page's edges it is
/// moved in so that all of it is on the page (here 5 pt from the right, 100 pt from the bottom).
#[test]
fn a_table_clicked_near_the_page_edge_stays_on_the_page() {
let mut h = harness();
assert!(h.state_mut().execute("form.add.text_table"));
h.run_steps(2);
let p = at(&h, 295.0, 300.0);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
let r = h.state().field_table_draft.as_ref().expect("the table's window").table.rect;
assert!([120.0, 290.0, 300.0, 400.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "on the 300 × 400 page: {r:?}");
}
/// Prepare a form ▸ Calculation order opens as a page of its own, with the form still being
/// prepared, says when nothing calculates, and Back returns to Prepare a form.
#[test]
+2 -1
View File
@@ -135,7 +135,7 @@ total matches the parity estimate.
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Done since: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. (#2892). Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Done since: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. (#2892). Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values.<br>Done since: Text field table: an area divided into rows and columns of named text fields, added as one undoable edit, with `form_add_field_table`; copying an existing field into a grid remains. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
@@ -205,6 +205,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| 2026-10-20 | minor | M6: Prepare a form ▸ Calculation order: formulas and values, what is wrong and Fix order, Up/Down, Alt+↑/↓ and drag, links on the page. Estimates unchanged. |
| 2026-10-20 | minor | M6: `form_calculation_order` reads, checks, fixes and sets the calculation order; an edited formula keeps its place in `/CO`. Estimates unchanged. |
| 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. |
| 2026-10-19 | minor | M6: Text field table (rows and columns of text fields as one edit, `form_add_field_table`); estimates unchanged |
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged |
+10 -5
View File
@@ -5025,11 +5025,16 @@ title = "Create multiple copies of a field in a grid"
area = "F"
tier = "P2"
milestone = "M6"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["form.add.text_table"]
tools = ["form_add_field_table"]
evidence = [
"crates/forms/src/table.rs::a_grid_fills_its_area_with_gaps_between_the_cells",
"crates/engine/src/field_table.rs::a_table_is_added_and_undone_as_one_step",
"crates/automation/tests/automation.rs::adding_a_field_table_through_tools",
"crates/ui-egui/tests/prepare.rs::a_text_field_table_is_drawn_set_up_and_added_in_one_step",
]
notes = "Prepare a form ▸ Text field table: drag an area, then set rows, columns, spacing, column widths (draggable dividers) and a name pattern per column in a movable window, with the table previewed on the page. Added as one undoable edit, or refused whole with the reason (a cell under 4 pt, a name with a period, a repeated or existing name); form_add_field_table does the same headlessly. Missing: copying an existing field of any type, with its properties, into a grid from its context menu, which is what this entry describes; the table only creates new text fields."
[[feature]]
id = "form.rename-lock-fields"