From d676fd44ea99b277d508455a7e107e4aa1346323 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Thu, 22 Oct 2026 03:27:31 +0200 Subject: [PATCH] =?UTF-8?q?M6:=20Prepare=20a=20form=20=E2=96=B8=20Text=20f?= =?UTF-8?q?ield=20table:=20rows=20and=20columns=20of=20text=20fields=20as?= =?UTF-8?q?=20one=20edit=20(#3479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 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 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 Co-authored-by: aiongg --- ATTRIBUTION.toml | 44 +- ROADMAP.md | 1 + crates/automation/src/forms.rs | 63 ++- crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 21 + crates/automation/tests/automation.rs | 59 +++ crates/engine/src/catalog.rs | 1 + crates/engine/src/commands.rs | 1 + crates/engine/src/field_table.rs | 131 +++++ crates/engine/src/lib.rs | 4 +- crates/forms/README.md | 8 + crates/forms/src/author.rs | 9 +- crates/forms/src/lib.rs | 6 +- crates/forms/src/table.rs | 663 ++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 19 +- crates/ui-egui/src/dialogs.rs | 11 + crates/ui-egui/src/field_table_ui.rs | 512 ++++++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 20 + crates/ui-egui/src/i18n/bg.tsv | 20 + crates/ui-egui/src/i18n/cs.tsv | 20 + crates/ui-egui/src/i18n/de.tsv | 20 + crates/ui-egui/src/i18n/el.tsv | 20 + crates/ui-egui/src/i18n/es.tsv | 24 + crates/ui-egui/src/i18n/fr.tsv | 20 + crates/ui-egui/src/i18n/hu.tsv | 20 + crates/ui-egui/src/i18n/it.tsv | 24 + crates/ui-egui/src/i18n/ja.tsv | 20 + crates/ui-egui/src/i18n/kk.tsv | 20 + crates/ui-egui/src/i18n/lt.tsv | 24 + crates/ui-egui/src/i18n/mod.rs | 8 +- crates/ui-egui/src/i18n/pl.tsv | 20 + crates/ui-egui/src/i18n/pt-br.tsv | 20 + crates/ui-egui/src/i18n/ru.tsv | 20 + crates/ui-egui/src/i18n/sl.tsv | 20 + crates/ui-egui/src/i18n/te.tsv | 20 + crates/ui-egui/src/i18n/tr.tsv | 20 + crates/ui-egui/src/i18n/uk.tsv | 20 + crates/ui-egui/src/i18n/vi.tsv | 24 + crates/ui-egui/src/i18n/zh-hans.tsv | 20 + crates/ui-egui/src/i18n/zh-hant.tsv | 20 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/prepare.rs | 44 +- crates/ui-egui/tests/prepare.rs | 233 ++++++++- docs/roadmap.md | 3 +- parity/acrobat-features.toml | 15 +- 45 files changed, 2274 insertions(+), 45 deletions(-) create mode 100644 crates/engine/src/field_table.rs create mode 100644 crates/forms/src/table.rs create mode 100644 crates/ui-egui/src/field_table_ui.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index c5e55f0631..23ecc1903b 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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]] diff --git a/ROADMAP.md b/ROADMAP.md index 96f6ede4b9..cb2b36028b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index baeb4dd521..0bcf29d826 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -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 { + 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::>>()) + .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::>>()) + .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 = 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 { if a.get("widget").is_some() && a.get("caption").is_none() { return Err(bad("widget requires caption")); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 31bb8da6ea..fc00bdfbed 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -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)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 59ecf04252..a6189fa5e2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -355,6 +355,27 @@ pub fn tools() -> Vec { }), &["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( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 2c87162d5d..13bfb0b65e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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::>() + }; + // 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"); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 551cbdc67c..c19e1e42c8 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -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), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9958763d19..18be797d20 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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"), diff --git a/crates/engine/src/field_table.rs b/crates/engine/src/field_table.rs new file mode 100644 index 0000000000..f3fd96551e --- /dev/null +++ b/crates/engine/src/field_table.rs @@ -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 { + 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}"); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2125a5b9d8..7a820c15ef 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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"). diff --git a/crates/forms/README.md b/crates/forms/README.md index b567009f8c..7df2cb9935 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -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 = grid.cells_for(names)?; // "Table1_1", "Table1_c2_1", … ``` ## Behaviour diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 53c90b0fcc..c0353a3b9a 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -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, + /// 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, 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) -> Result, 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) -> 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 = 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 = 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 = (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); + } + } + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3d78f952d6..bbce17eee2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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"), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8bff5ca8d7..64863f86e5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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; diff --git a/crates/ui-egui/src/field_table_ui.rs b/crates/ui-egui/src/field_table_ui.rs new file mode 100644 index 0000000000..9314dc927d --- /dev/null +++ b/crates/ui-egui/src/field_table_ui.rs @@ -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>, + /// 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, TableError>, + edit: Result, +} + +/// 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, +} + +/// 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 { + 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 { + 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, Arc) { + 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(ui: &mut egui::Ui, value: &mut N, range: RangeInclusive, 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 { + 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"]); + } +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index f6b3d093d5..41c62f3744 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -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} بالفعل diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 90e152c27b..7d5b84cf42 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1366cb2546..e5c32b2693 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ac136cd91a..586cd001e1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 133e8ca8e6..72e79d4aae 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7c7b7c8d71..a8056676c6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index aa83f81970..112359b607 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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à diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 123be343f3..72541431c8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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ő diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 71715dc5d7..53e16c5cba 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f73e753def..c34658a320 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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} という名前のフィールドは既にあります diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 7f113d4bfc..60fad67160 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -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} атты өріс бар diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 6448c6949e..ad2bf574fd 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 20c0fd0467..75171dfb29 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -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); diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 578b512bb7..00d696417a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ee12fe7ab2..ef8704423f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3baec45600..fa2c6e6c33 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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} уже существует diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 5ba9dc90c3..a829e8d00a 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index e0d6a2b34c..ad682462e9 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -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} అనే ఫీల్డ్ ఇప్పటికే ఉంది diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 48d34a30da..cb95010d4b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 246a4a2c56..a23f108b53 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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} уже існує diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 56caf38859..e25d358364 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 645c0ab301..f62891fb0e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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} 的字段 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 712f0666a5..0b5d8d6462 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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} 的欄位 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c3b882779c..f404f564d0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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)>, /// Duplicate Field: which field and onto which pages. pub duplicate_draft: Option, + /// Text field table: the table being set up, and the page it goes on. + pub field_table_draft: Option, /// 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, diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index a46611274d..fc01684bd2 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -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)); diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index 25e70fb810..61bfe472a6 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -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 { + 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 = 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::() - 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] diff --git a/docs/roadmap.md b/docs/roadmap.md index 201cc1d6c3..c7c2f44797 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -135,7 +135,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
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.
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.
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).
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
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).
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
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.
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.
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
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).
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
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.
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
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 | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ad00b0115d..724cbb5604 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"