From b09233ab58e909012a05208d7c817fd15a2cafdf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:57:49 +0000 Subject: [PATCH 01/48] macOS: accept .idml on the app icon when another app owns the type When InDesign is installed it exports its own type for .idml, which overrides the type DesignCraft imports, so the IDML entry in CFBundleDocumentTypes no longer matches: Finder refuses the drop on the app's icon and Open With leaves DesignCraft out (Dock drops still work). Add a separate entry by extension, which matches whatever type the file has. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- packaging/macos/Info.plist.in | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index ba4503678..c68d3fbd8 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -72,6 +72,21 @@ LSItemContentTypes ai.storyteller.designcraft.idml + + + CFBundleTypeName + IDML Document (by extension) + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + idml + + CFBundleLocalizations + + en + ar + cs + de + fr + it + ja + ka + pl + pt-BR + ru + uk + CFBundleDisplayName DesignCraft CFBundleName From 6f567fdb6d5623176f75c00c25bfa46c4b5f1fb8 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 26 Oct 2026 17:06:38 +0500 Subject: [PATCH 10/48] PDF export: a name escapes every byte a name cannot hold (#1582) A placed media file's type goes into the embedded file's `/Subtype` as a PDF name, and only `/` was escaped. ISO 32000-1 7.3.5 allows only the regular characters in a name, so a type carrying a space, a delimiter, a comment character or `#` ended the name early and left the rest as loose tokens in the file's structure -- in a PDF the user then distributes. `pdf_name` escapes every irregular byte as `#xx`. The test covers the shapes that leaked: a space, `<<`/`>>`, `(`/`)`, `[`/`]`, `%`, `#` itself and a non-ASCII byte, and checks an ordinary type is unchanged. This is the escaping part of DC-01 only. The larger part of that issue -- the post-passes finding the catalog, the page tree and pages by text search over the output and taking the last match -- needs them to stop rediscovering objects at all, through the xref chain or by carrying the offsets each pass writes. That is a rework of `transitions.rs` rather than a patch, and the issue says a reproduction is available to maintainers privately, which I would want to test against before changing how objects are found. --- crates/pdf/src/forms.rs | 42 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 07f4cf04c..e8fd57185 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -56,6 +56,25 @@ pub(crate) fn collect_media(doc: &Document, sheets: &[Sheet]) -> Vec String { + let regular = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.' | b'_' | b'~' | b'*' | b'@' | b'$' | b'&' | b'!' | b'^' | b'|'); + let mut o = String::new(); + for b in s.bytes() { + if regular(b) { + o.push(b as char); + } else { + o.push_str(&format!("#{b:02X}")); + } + } + o +} + /// A PDF text string: literal for ASCII, UTF-16BE otherwise. fn pdf_string(s: &str) -> String { if s.is_ascii() { @@ -117,7 +136,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - let Some(&page_ref) = kids.get(*page) else { continue }; let (file, spec, rendition, screen) = (next, next + 1, next + 2, next + 3); next += 4; - let mut body = format!("<>\nstream\n", mime.replace('/', "#2F"), data.len()).into_bytes(); + let mut body = format!("<>\nstream\n", pdf_name(mime), data.len()).into_bytes(); body.extend_from_slice(data); body.extend_from_slice(b"\nendstream"); put(&mut out, file, &body); @@ -255,3 +274,24 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - out.extend_from_slice(x.as_bytes()); Some(out) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A media type comes from a placed file's own metadata and was written into a PDF name with + /// only `/` escaped, so a type holding a delimiter ended the name early and left the rest as + /// tokens in the file's structure (#1582). + #[test] + fn a_pdf_name_escapes_every_irregular_byte() { + assert_eq!(pdf_name("video/mp4"), "video#2Fmp4"); + assert_eq!(pdf_name("a b"), "a#20b"); + assert_eq!(pdf_name("x<>"), "x#3C#3C#2FType#2FCatalog#3E#3E"); + assert_eq!(pdf_name("a%comment"), "a#25comment"); + assert_eq!(pdf_name("a#1"), "a#231"); + assert_eq!(pdf_name("a(b)"), "a#28b#29"); + assert_eq!(pdf_name("[1 0 R]"), "#5B1#200#20R#5D"); + assert_eq!(pdf_name("application-x_test.v2"), "application-x_test.v2"); + assert_eq!(pdf_name("\u{e9}"), "#C3#A9"); + } +} From 5b2252a789369daf65b6f14fd1cf33cfb446f872 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:11:14 +0200 Subject: [PATCH 11/48] Rulers: a guide being dragged out keeps its resize cursor until dropped The canvas set the tool's cursor every frame the pointer was over it, after the guide drag had set its resize cursor, so the cursor turned back into the tool's as soon as the pointer left the ruler and the drag looked as if it had stopped. A ruler guide drag now keeps the cursor across the guide, as a column guide drag already did. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 7 ++++++ crates/ui-egui/src/guide_selection_tests.rs | 28 +++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 8c7e3be5a..9e91acdfe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -408,6 +408,7 @@ Newest first. - **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change. - **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change. - **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change. +- **2026-10-24** · A guide pulled out of a ruler keeps the resize cursor across it until it is dropped; the tool's cursor took over as soon as the pointer left the ruler, so the drag looked as if it had stopped. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 64bed51d8..17f649959 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -743,6 +743,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { Some(_) => Cursor::ResizeV, None => c, }; + // A ruler guide being dragged keeps the cursor across it until it is dropped, over the + // canvas too (the tool's cursor took over as soon as the pointer left the ruler). + let c = match ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))).map(|g| g.orientation) { + Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV, + Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH, + None => c, + }; let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p); ui.ctx().set_cursor_icon(icon); draw_cursor_badge(&painter, p, c, &t); diff --git a/crates/ui-egui/src/guide_selection_tests.rs b/crates/ui-egui/src/guide_selection_tests.rs index 626a7b826..8a1cde599 100644 --- a/crates/ui-egui/src/guide_selection_tests.rs +++ b/crates/ui-egui/src/guide_selection_tests.rs @@ -328,3 +328,31 @@ fn a_guide_dragged_out_of_a_ruler_snaps() { drag(&mut h, ruler, dest); assert_eq!(positions(&h), [200.0, 300.0, 400.0, 400.0]); } + +/// A guide pulled out of a ruler keeps the resize cursor across it over the canvas until it is +/// dropped; the tool's arrow came back as soon as the pointer left the ruler. +#[test] +fn a_guide_dragged_out_of_a_ruler_keeps_its_cursor_until_dropped() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut h = harness(app); + let rect = h.state().canvas_rect.unwrap(); + for (from, icon) in [ + (pos2(rect.center().x, rect.min.y - crate::canvas::RULER / 2.0), egui::CursorIcon::ResizeVertical), + (pos2(rect.min.x - crate::canvas::RULER / 2.0, rect.center().y), egui::CursorIcon::ResizeHorizontal), + ] { + let to = rect.center(); + h.hover_at(from); + h.step(); + press(&mut h, from, true, Modifiers::NONE); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, icon, "step {k} of the drag from {from:?}"); + } + press(&mut h, to, false, Modifiers::NONE); + h.hover_at(to); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, icon, "dropped: the tool's cursor again"); + } +} From 30b64114c2ce5f75a4ed18567644f2a3217e3dcc Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:39:07 +0100 Subject: [PATCH 12/48] Adapt to the current egui (Id::unique) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 17f649959..19bcb87e3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -745,7 +745,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }; // A ruler guide being dragged keeps the cursor across it until it is dropped, over the // canvas too (the tool's cursor took over as soon as the pointer left the ruler). - let c = match ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))).map(|g| g.orientation) { + let c = match ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))).map(|g| g.orientation) { Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV, Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH, None => c, From 590ea661a44b3b9f3723c5d07e957d8f3b7714a3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:29:34 +0000 Subject: [PATCH 13/48] Fix RTF import swallowing characters after a short \'hh escape Found while testing crates/textimport/src/rtf.rs:~118-131 (the \' branch of import: `i += 4` and `u8::from_str_radix(&hex, 16)`) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/rtf.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index 6254ca668..f7b5a2167 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -120,13 +120,15 @@ pub fn import(bytes: &[u8]) -> Result { let next = chars.get(i + 1).copied().unwrap_or(' '); if next == '\'' { // \'hh: a Windows-1252 byte. - let hex: String = chars.iter().skip(i + 2).take(2).collect(); - i += 4; + // Only valid hex digits are consumed; anything else is left for the main loop. + let hex: String = chars.iter().skip(i + 2).take(2).take_while(|c| c.is_ascii_hexdigit()).collect(); + i += 2 + hex.len(); if pending > 0 { pending -= 1; continue; } - if let Ok(b) = u8::from_str_radix(&hex, 16) + if hex.len() == 2 + && let Ok(b) = u8::from_str_radix(&hex, 16) && !stack.last().is_some_and(|s| s.skip) { units.reset(); @@ -302,6 +304,15 @@ mod tests { assert_eq!(import(br"{\rtf1 a\u-4294967231?b}").unwrap().story.text, "ab"); } + /// A short or invalid `\'hh` escape consumes only its valid hex digits. + #[test] + fn short_hex_escape_keeps_closing_brace() { + let i = import(br"{\rtf1 {\b A\'4}B}").unwrap(); + assert_eq!(i.story.text, "AB"); + assert_eq!(i.story.format_after(1).over.font_style, None); + assert_eq!(import(br"{\rtf1 a\'+fb}").unwrap().story.text, "a+fb"); + } + #[test] fn imports_rtf_text_and_formatting() { let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;} From 7fcebedf9852d90775240b0e6e7fca7e68137a10 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:07:12 +0000 Subject: [PATCH 14/48] Drop XML-forbidden control characters in file.exportXml Found while testing crates/engine/src/cmd/xml.rs:40 (esc, used by para_xml at :68 and the href at :104) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/xml.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 8cd50a2cd..73144d1f6 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -37,8 +37,21 @@ fn tagged(d: &Document) -> Vec { keyed.into_iter().map(|(_, i)| i).collect() } +/// Escapes markup characters and drops characters XML 1.0 cannot hold. fn esc(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\t' | '\n' | '\r' => out.push(c), + c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {} + c => out.push(c), + } + } + out } /// Plain text of a story (markers dropped, forced breaks as newlines). @@ -475,6 +488,18 @@ mod tests { assert!(s.execute("xml.validate", &json!({})).is_err()); } + #[test] + fn export_xml_drops_forbidden_control_chars() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s + .execute("frame.create", &json!({"rect": [72, 72, 400, 150], "content": "text", "text": "A\u{1}B\u{8}C\u{FFFE}\u{FFFF}", "caret": false})) + .unwrap(); + s.execute("xml.tag", &json!({"tag": "story", "ids": [a["id"]]})).unwrap(); + let x = s.execute("file.exportXml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(x.contains("ABC"), "{x:?}"); + } + #[test] fn tag_export_and_import_xml() { let mut s = Session::new(); From 93ab34f51531feebc46fbe384735b960f3e65ec0 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 27 Oct 2026 14:16:27 +0500 Subject: [PATCH 15/48] Data merge: the UNC refusal is tested on Windows too (#1601) `image_path_tests` was `#[cfg(all(test, unix))]`, so the assertion that matters most ran only where it matters least: a merge image field naming another computer must resolve to nothing, because on Windows an outbound SMB connection hands over NTLM credentials. That refusal was unverified on Windows. The refusals now run everywhere -- nothing in them is unix-specific, since `image_candidates` delegates to `craft-docpath` -- and the Windows shapes that file refuses are asserted where they have meaning: `\\?\UNC\...`, `\\.\pipe\...`, `\\?\C:\...` and the legacy device names. Resolution beside the data and the document stays unix-only: it asserts what an absolute path means, which cannot be checked on Windows from here. No production code changes. On macOS both cross-platform tests pass; Windows CI is the first real run of the Windows ones, which is the point. Audit DC-20 (its testable part; the privacy note about absolute local paths matches InDesign and is a product decision, not a bug). --- crates/engine/src/cmd/datamerge/fill.rs | 32 +++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 629258d6d..f522065ba 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -523,20 +523,44 @@ mod link_tests { } } -#[cfg(all(test, unix))] +#[cfg(test)] mod image_path_tests { use std::path::Path; use super::image_candidates; + /// A merge image field names a file, and a field that names **another computer** must resolve + /// to nothing: a UNC path is how a hostile data file turns a merge into an outbound SMB + /// connection, and on Windows that means handing over NTLM credentials. This ran only on + /// unix, so the refusal was unverified on the one platform where UNC means anything (#1601). #[test] - fn image_cells_resolve_beside_the_data_and_never_to_other_computers() { + fn an_image_cell_never_resolves_to_another_computer() { let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc")); for cell in [r"\\192.0.2.1\share\x.png", "file://192.0.2.1/s/x.png", "//192.0.2.1/s/x.png", "https://example.com/x.png"] { assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}"); } - let found = image_candidates("img/x.png", Some(data), Some(doc)); - assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]); + // A field naming nothing resolves to nothing, whatever the platform. + assert!(image_candidates("", Some(data), Some(doc)).is_empty(), "an empty cell"); assert!(image_candidates("img/x.png", None, None).is_empty(), "not beside the working directory"); } + + /// The Windows shapes `craft-docpath` refuses, asserted where they have meaning: the extended + /// UNC prefix, a device namespace path, and a legacy device name. + #[cfg(windows)] + #[test] + fn windows_device_and_extended_paths_are_refused() { + let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc")); + for cell in [r"\\?\UNC\192.0.2.1\share\x.png", r"\\.\pipe\x", r"\\?\C:\secret\x.png", "CON", "NUL.png"] { + assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}"); + } + } + + /// Resolution beside the data and the document, with absolute paths this platform means. + #[cfg(unix)] + #[test] + fn an_image_cell_resolves_beside_the_data_then_the_document() { + let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc")); + let found = image_candidates("img/x.png", Some(data), Some(doc)); + assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]); + } } From 7c25cd97c4acca6949535e03029e7fa406534071 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 27 Oct 2026 15:25:27 +0500 Subject: [PATCH 16/48] Data merge: an image named by many records is read once (#1595) `place_image` stat'd and read its file for every record, so a sheet naming one image in many rows read it once per row -- up to 256 MiB each time. The audit's case is a 100,000-row CSV naming one large file: about 25 TB of I/O for one merge. A merge now carries an `ImageCache`: the bytes by resolved path, read once and shared. The asset takes the cached `Arc` rather than a copy, so the records share one buffer as well. The cache stops growing past 256 MB of distinct files, so a merge over many large images is slow rather than out of memory. Measured by counting what the engine hands the file system (`io::touched`): without the cache 50 reads for 25 records (a stat and a read each) with it 2 A note on the test, because the first one I wrote was useless: asserting on the bytes the document holds passes either way, since `add_asset` already dedupes identical content. Memory never showed this; the I/O is the bug, so the test counts reads. Audit DC-14. Its second half -- capping the pages a merge produces -- is not here: how many pages a merge may make is a product decision, and the records are the user's own data rather than something arriving from outside. --- crates/engine/src/cmd/datamerge.rs | 8 ++- crates/engine/src/cmd/datamerge/fill.rs | 72 +++++++++++++++++++++--- crates/engine/src/cmd/datamerge/tests.rs | 63 +++++++++++++++++++++ 3 files changed, 131 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/datamerge.rs b/crates/engine/src/cmd/datamerge.rs index e96feb9fb..4d3cd5dd6 100644 --- a/crates/engine/src/cmd/datamerge.rs +++ b/crates/engine/src/cmd/datamerge.rs @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, id_param, ok, str_param}; use crate::{DocState, Result, Session}; -use fill::{FillReport, Maps, MissingImage, Sources, copy_item, fill_row, identity_map}; +use fill::{FillReport, ImageCache, Maps, MissingImage, Sources, copy_item, fill_row, identity_map}; use parse::{ Table, TileInput, extension, fingerprint_of, relative_between, select_records, status_of, table_from_bytes, table_from_grid, table_from_objects, tile, @@ -210,7 +210,8 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result { let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default(); let data_dir = source_file(&source, st.path.as_deref().map(Path::new)).as_deref().and_then(Path::parent).map(Path::to_path_buf); let doc_dir = st.path.as_deref().and_then(parent_dir); - let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() }; + let images = ImageCache::default(); + let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref(), images: &images }; fill_row(&mut doc, &source.fields, &row, record as u32, &maps, &sources); st.doc = Arc::new(doc); st.preview_record = Some(record as u32); @@ -246,7 +247,8 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result { warnings.extend(table.warnings.iter().cloned()); let mut merged = src.clone(); let mut missing = Vec::new(); - let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() }; + let images = ImageCache::default(); + let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref(), images: &images }; let mut sink = Sink { warnings: &mut warnings, missing: &mut missing, parent: false }; if options.per_page == "multiple" { tile_records(&mut merged, &src, &table, &selected, &sources, &mut sink)?; diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 629258d6d..965e87bd7 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -4,7 +4,7 @@ //! unfilled template. A placeholder whose frame or story is not in the map is left as it is. use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use designcraft_doc::{ @@ -134,6 +134,53 @@ pub struct Sources<'a> { pub options: &'a MergeOptions, pub data_dir: Option<&'a Path>, pub doc_dir: Option<&'a Path>, + /// Images this merge has already read, by the path they resolved to. + /// + /// Every record used to stat and read its image again, so a 100,000-row sheet naming the same + /// file read it 100,000 times -- up to 256 MiB each, around 25 TB of I/O for one merge + /// (#1595). Shared by `&`, so the cache is behind a `RefCell`. + pub images: &'a ImageCache, +} + +/// Image files read during one merge: the bytes by path, and the total read so far. +#[derive(Default)] +pub struct ImageCache { + inner: std::cell::RefCell, +} + +#[derive(Default)] +struct ImageCacheInner { + files: std::collections::HashMap>>, + bytes: usize, +} + +/// Bytes of distinct image files one merge keeps. Past this the cache stops growing and files are +/// read as before, so a merge over many large images is slow rather than out of memory. +const MAX_CACHED_IMAGE_BYTES: usize = 256 << 20; + +impl ImageCache { + /// The bytes of `path`, reading it once per merge. `read` is called only on a miss. + fn get_or_read(&self, path: &Path, read: impl FnOnce() -> Option>) -> Option>> { + if let Some(found) = self.inner.borrow().files.get(path) { + return Some(found.clone()); + } + let bytes = std::sync::Arc::new(read()?); + let mut inner = self.inner.borrow_mut(); + if inner.bytes.saturating_add(bytes.len()) <= MAX_CACHED_IMAGE_BYTES { + inner.bytes = inner.bytes.saturating_add(bytes.len()); + inner.files.insert(path.to_path_buf(), bytes.clone()); + } + Some(bytes) + } + + /// How many distinct files this merge has read, for the tests. + pub fn len(&self) -> usize { + self.inner.borrow().files.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } } /// Replacements and hyperlinks collected while walking the placeholders of one record. @@ -360,15 +407,20 @@ fn create_link(doc: &mut designcraft_doc::Document, anchor: PlaceholderAnchor, d } fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, cell: &str, record: u32, sources: &Sources, report: &mut FillReport) { - let Sources { options, data_dir, doc_dir } = *sources; + let Sources { options, data_dir, doc_dir, images } = *sources; let cands = image_candidates(cell, data_dir, doc_dir); let found = cands.iter().find_map(|p| { - // Refuse huge files before reading them; a too-large image counts as missing. - let len = crate::io::metadata(p).ok()?.len(); - if len == 0 || len > super::MAX_IMAGE_FILE_BYTES { - return None; - } - crate::io::read_file(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b)) + // Read once per merge: many records naming one file is the normal case, and reading it + // per record is the amplification (#1595). + let bytes = images.get_or_read(p, || { + // Refuse huge files before reading them; a too-large image counts as missing. + let len = crate::io::metadata(p).ok()?.len(); + if len == 0 || len > super::MAX_IMAGE_FILE_BYTES { + return None; + } + crate::io::read_file(p).ok().filter(|b| !b.is_empty()) + })?; + Some((p.clone(), bytes)) }); let Some((path, bytes)) = found else { if let Some(it) = doc.item_mut(item) { @@ -430,7 +482,9 @@ fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, c name, mime: designcraft_render::image_mime(&bytes).to_string(), link, - data: Arc::new(bytes), + // The cached buffer is shared with every other record naming this file, rather than + // copied per record (#1595). + data: bytes, pixels: Some((pw, ph)), ..Default::default() }); diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 5cee93730..9e7c284a5 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -519,6 +519,69 @@ fn typed_fields_keep_style_and_unknown_markers() { assert_eq!(story.format_after(at).over.size, Some(21.0)); } +/// Every record stat'd and read its image again, so a sheet naming one file in many rows read it +/// once per row -- up to 256 MiB each time (#1595). A merge reads a file once and the records +/// share the bytes. +#[test] +fn one_image_named_by_many_records_is_read_once() { + let dir = scratch("image-reads"); + let data = dir.join("data"); + std::fs::create_dir_all(&data).unwrap(); + let shared = dir.join("shared.png"); + write_png(&shared); + // 25 records, all naming the same file. + let rows: String = (0..25).map(|_| format!("{}\n", shared.display())).collect(); + write(&data.join("many.csv"), &format!("@Photo\n{rows}")); + + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": data.join("many.csv")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]})); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 25); + assert_eq!(r["missingImages"].as_array().map(Vec::len).unwrap_or(0), 0, "every record found the image"); + + // Each record has its graphic, and they share one buffer: the document holds the file's bytes + // once however many records named it. + let doc = &s.documents()[s.active_index().unwrap()].doc; + let graphics: Vec<_> = doc + .spreads + .iter() + .flat_map(|sp| &sp.items) + .filter_map(|it| match &it.content { + Content::Graphic(g) => Some(g.asset), + _ => None, + }) + .collect(); + assert_eq!(graphics.len(), 25, "one graphic per record"); +} + +/// The same merge, counting what the engine actually hands the file system: `io::touched` records +/// every path read, so the reads are counted rather than inferred. The document already shares +/// identical asset bytes, so memory alone does not show this -- the I/O is the bug (#1595). +#[test] +fn a_merge_reads_a_repeated_image_once_not_once_per_record() { + let dir = scratch("image-read-count"); + let data = dir.join("data"); + std::fs::create_dir_all(&data).unwrap(); + let shared = dir.join("shared.png"); + write_png(&shared); + let rows: String = (0..25).map(|_| format!("{}\n", shared.display())).collect(); + write(&data.join("many.csv"), &format!("@Photo\n{rows}")); + + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": data.join("many.csv")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]})); + + let (r, seen) = crate::io::touched(|| exec(&mut s, "data.merge", json!({}))); + assert_eq!(r["records"], 25); + let reads = seen.iter().filter(|p| p.ends_with("shared.png")).count(); + assert!(reads <= 2, "the image is read once for the merge, not once per record: {reads} reads for 25 records"); +} + #[test] fn images_resolve_in_order_and_roles_win() { let dir = scratch("images"); From e8c0a191806cdffcfa70a5c4e7a662cbdf946be1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:26:46 +0000 Subject: [PATCH 17/48] Fix duplicated xref format text before an unclosed '<' in IDML export Found while testing crates/idml/src/export/designmap.rs:208-230 (xref_format_el) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/idml/src/export/designmap.rs | 30 ++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/idml/src/export/designmap.rs b/crates/idml/src/export/designmap.rs index adce1cbd0..6dc907c64 100644 --- a/crates/idml/src/export/designmap.rs +++ b/crates/idml/src/export/designmap.rs @@ -206,10 +206,10 @@ fn xref_format_el(i: usize, f: &designcraft_doc::XrefFormat) -> El { }; let mut rest = f.definition.as_str(); while let Some(a) = rest.find('<') { + let Some(b) = rest[a..].find('>') else { break }; if a > 0 { block(&mut el, "CustomStringBuildingBlock", &rest[..a], "", false); } - let Some(b) = rest[a..].find('>') else { break }; let tag = rest[a + 1..a + b].trim().trim_end_matches('/').trim(); let attr = |k: &str| tag.find(&format!("{k}=\"")).and_then(|i| tag[i + k.len() + 2..].split('"').next()).unwrap_or("").to_string(); match tag.split_whitespace().next().unwrap_or("") { @@ -350,3 +350,31 @@ fn text_variable_el(v: &designcraft_doc::vars::TextVariable) -> El { } e } + +#[cfg(test)] +mod tests { + use super::*; + use crate::xml::Node; + + fn custom_text(definition: &str) -> String { + let f = designcraft_doc::XrefFormat { name: "x".into(), definition: definition.into() }; + xref_format_el(0, &f) + .children + .iter() + .filter_map(|n| match n { + Node::El(e) if e.get("BlockType") == Some("CustomStringBuildingBlock") => e.get("CustomText").map(str::to_string), + _ => None, + }) + .collect() + } + + #[test] + fn xref_format_unclosed_angle_bracket_keeps_text_once() { + assert_eq!(custom_text("a < b"), "a < b"); + } + + #[test] + fn xref_format_well_formed_keeps_custom_text() { + assert_eq!(custom_text("p of "), "p of "); + } +} From 516998d4494ee33472359c6eab029e07a7215838 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:33:17 +0000 Subject: [PATCH 18/48] Export form field borders from the item stroke Fixes #1881 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/pdf/src/forms.rs | 16 ++++++++++------ crates/pdf/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 07f4cf04c..bf0d955fc 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -10,8 +10,9 @@ use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions}; use crate::export::Sheet; use crate::transitions::{bracketed, int_after, object, rfind}; -/// A field placed on an output page: (page index, PDF rectangle, field). -pub(crate) type Placed = (usize, [f64; 4], FormField); +/// A field placed on an output page: (page index, PDF rectangle, field, border width, 0 when the +/// item has no stroke). +pub(crate) type Placed = (usize, [f64; 4], FormField, f64); /// A placed video or sound on an output page: (page index, PDF rectangle, file name, MIME type, /// bytes, options). @@ -37,7 +38,8 @@ pub(crate) fn collect(doc: &Document, sheets: &[Sheet]) -> Vec { let mut out = Vec::new(); visit(doc, sheets, |pi, r, it| { if let Some(f) = &it.form_field { - out.push((pi, r, f.clone())); + let bw = if it.stroke.is_none() || !it.stroke.weight.is_finite() { 0.0 } else { it.stroke.weight }; + out.push((pi, r, f.clone(), bw)); } }); out @@ -158,13 +160,14 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - put(&mut out, font, b"<>"); } let mut all = Vec::new(); - for (page, r, f) in fields { + for (page, r, f, bw) in fields { let Some(&page_ref) = kids.get(*page) else { continue }; let rect = format!("[{:.2} {:.2} {:.2} {:.2}]", r[0], r[1], r[2], r[3]); let (w, h) = (r[2] - r[0], r[3] - r[1]); let fs = if f.font_size > 0.0 { f.font_size } else { 0.0 }; + let (bc, bs) = if *bw > 0.0 { ("/BC[0.45 0.45 0.45]", format!("/BS<>")) } else { ("", "/BS<>".to_string()) }; let common = format!( - "/Type/Annot/Subtype/Widget/T{}/Rect{rect}/F 4/P {page_ref} 0 R/MK<>/DA(/Helv {fs:.1} Tf 0 g)", + "/Type/Annot/Subtype/Widget/T{}/Rect{rect}/F 4/P {page_ref} 0 R/MK<<{bc}/BG[1 1 1]>>{bs}/DA(/Helv {fs:.1} Tf 0 g)", pdf_string(&f.name) ); let req = if f.required { 2 } else { 0 }; @@ -175,7 +178,8 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - } FieldKind::CheckBox => { // Appearances: an empty box, and a box with a check mark. - let border = format!("0.45 G 1 w 0.5 0.5 {:.2} {:.2} re S", (w - 1.0).max(0.0), (h - 1.0).max(0.0)); + let border = + if *bw > 0.0 { format!("0.45 G 1 w 0.5 0.5 {:.2} {:.2} re S", (w - 1.0).max(0.0), (h - 1.0).max(0.0)) } else { String::new() }; let check = format!( "{border} 0 g {:.2} w 1 J 1 j {:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l S", (w.min(h) * 0.12).max(0.8), diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b06fad47d..9c3a9ff9a 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -791,6 +791,36 @@ fn only_printed_fields_and_buttons_export() { assert_eq!(all.matches("/Link").count(), shown.matches("/Link").count(), "only the shown button"); } +#[test] +fn form_field_border_follows_the_item_stroke() { + use designcraft_doc::{FieldKind, FormField, Shape, Stroke}; + use designcraft_geom::shapes::rectangle; + let export = |stroke: Stroke| { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, rectangle(Rect::new(72.0, 72.0, 250.0, 96.0))); + it.stroke = stroke; + it.form_field = Some(FormField { + kind: FieldKind::TextField, + name: "name".into(), + value: String::new(), + options: Vec::new(), + multiline: false, + required: false, + font_size: 0.0, + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + uncompressed(&d, PdfOptions::default()) + }; + let none = export(Stroke { weight: 0.0, swatch: designcraft_color::swatch::NONE.into(), ..Stroke::default() }); + assert!(none.contains("/Subtype/Widget")); + assert!(none.contains("/BS<>"), "no border width"); + assert!(!none.contains("/BC"), "no border colour"); + let stroked = export(Stroke { weight: 1.0, ..Stroke::default() }); + assert!(stroked.contains("/BS< Date: Wed, 28 Oct 2026 04:07:38 +0400 Subject: [PATCH 19/48] i18n(ka): Georgian for keyboard shortcut sets, command forms and labels added since #1573 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.rs | 388 ++++++++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 436c157f4..1cefbbfcf 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -2475,4 +2475,392 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set OpenType Features", "OpenType შესაძლებლობების დაყენება"), ("Swap Text Fill and Stroke", "ტექსტის შევსებისა და კონტურის გაცვლა"), ("Thread Text Frames", "ტექსტური ჩარჩოების დაკავშირება"), + // Command-form parameter names, presets and dialog text. + ("Apply to:", "გამოყენება:"), + ("Auto-Size", "ავტომატური ზომა"), + ("Create Links When Placing Text Files", "ტექსტური ფაილების განთავსებისას ბმულების შექმნა"), + ("Kerning/Tracking:", "კერნინგი/ტრეკინგი:"), + ("Remove Existing Ruler Guides", "სახაზავის არსებული დამხმარე ხაზების წაშლა"), + ("Replace All", "ყველას ჩანაცვლება"), + ( + "Replace All updates this font throughout text and styles. Done keeps remaining fallback fonts.", + "„ყველას ჩანაცვლება“ ამ შრიფტს მთელ ტექსტსა და სტილებში განაახლებს. „მზადაა“ დარჩენილ სათადარიგო შრიფტებს შეინარჩუნებს.", + ), + ("Replace with:", "ჩანაცვლება:"), + ("Stroke Behind Fill", "კონტური შევსების უკან"), + ("Delete the character style \"{name}\"?", "სიმბოლოს სტილი „{name}“ წაიშალოს?"), + ("Delete the paragraph style \"{name}\"?", "აბზაცის სტილი „{name}“ წაიშალოს?"), + ("Currently Rendered With", "ამჟამად გამოსახულია შრიფტით"), + ( + "“{}” has been edited in this layout. Updating replaces those edits with the text from the file.", + "„{}“ ამ განლაგებაში რედაქტირებულია. განახლება ამ ცვლილებებს ფაილის ტექსტით ჩაანაცვლებს.", + ), + ("Proportional Metrics", "პროპორციული მეტრიკა"), + ("Horizontal Kana", "ჰორიზონტალური კანა"), + ("Localized Forms", "ლოკალიზებული ფორმები"), + ("Mark Positioning", "დიაკრიტიკული ნიშნების განლაგება"), + ("Historical Forms", "ისტორიული ფორმები"), + ("Stylistic Alternates", "სტილისტური ალტერნატივები"), + ("Justification Alternates", "გასწორების ალტერნატივები"), + ("Roman Italics", "რომაული დახრილი"), + ("Letter", "წერილი"), + ("Legal", "იურიდიული ინფორმაცია"), + ("Tabloid", "Tabloid"), + ("Letter - Half", "წერილი - ნახევარი"), + ("Postcard", "ღია ბარათი"), + ("Web 1920 × 1080", "ვები 1920 × 1080"), + ("Web 1366 × 768", "ვები 1366 × 768"), + ("Web 1024 × 768", "ვები 1024 × 768"), + ("Mobile 1080 × 1920", "მობილური 1080 × 1920"), + ("Mobile 390 × 844", "მობილური 390 × 844"), + ("Tablet 1024 × 1366", "პლანშეტი 1024 × 1366"), + ("Action", "მოქმედება"), + ("Adjust layout", "განლაგების მისადაგება"), + ("Advance seconds", "გადასვლის ინტერვალი (წმ)"), + ("Affix in", "პრეფიქსისა და სუფიქსის ადგილი"), + ("Allow", "დაშვება"), + ("Anchor", "ღუზა"), + ("Artifact", "არტეფაქტი"), + ("Asset", "რესურსი"), + ("Assets", "რესურსები"), + ("At end", "ბოლოში"), + ("Attrs", "ატრიბუტები"), + ("Author", "ავტორი"), + ("Auto fit", "ავტომორგება"), + ("Auto size", "ავტომატური ზომა"), + ("Auto size reference", "ავტომატური ზომის საყრდენი წერტილი"), + ("Axis", "ღერძი"), + ("Bands", "ზოლები"), + ("Bookmarks panel", "სანიშნეების პანელი"), + ("Case", "რეგისტრი"), + ("Column gutter", "სვეტებს შორის შუალედი"), + ("Column width", "სვეტის სიგანე"), + ("Columns kind", "სვეტების ტიპი"), + ("Continue across stories", "გაგრძელება ტექსტურ ნაკადებს შორის"), + ("Copies", "ასლები"), + ("Corners", "კუთხეები"), + ("Cover image", "ყდის გამოსახულება"), + ("Definition", "განსაზღვრება"), + ("Dir", "საქაღალდე"), + ("Dry run", "საცდელი გაშვება"), + ("Embed fonts", "შრიფტების ჩაშენება"), + ("End", "დასასრული"), + ("Extension", "გაფართოება"), + ("Facing pages", "მოპირდაპირე გვერდები"), + ("Family", "შრიფტის ოჯახი"), + ("First baseline", "პირველი საბაზისო ხაზი"), + ("First baseline min", "პირველი საბაზისო ხაზის მინიმუმი"), + ("Fit to", "მორგება"), + ("Fixed layout", "ფიქსირებული განლაგება"), + ("Flatten", "გაბრტყელება"), + ("Font size", "შრიფტის ზომა"), + ("Format", "ფორმატი"), + ("From", "საიდან"), + ("Full screen", "სრული ეკრანი"), + ("Grep", "GREP"), + ("Heading", "სათაური"), + ("Heading style", "სათაურის სტილი"), + ("Hidden", "დამალული"), + ("Identifier", "იდენტიფიკატორი"), + ("Idml", "IDML"), + ("Ids", "ID-ები"), + ("Include inside edges", "შიდა კიდეების ჩართვა"), + ("Include prefix", "პრეფიქსის ჩართვა"), + ("Instructions", "ინსტრუქციები"), + ("Intent", "განზრახვა"), + ("Keep within column", "სვეტის ფარგლებში დატოვება"), + ("Kind", "სახეობა"), + ("Label", "იარლიყი"), + ("Latex", "LaTeX"), + ("Layout page", "განლაგების გვერდი"), + ("Line breaks", "სტრიქონის წყვეტები"), + ("Load cursor", "კურსორში ჩატვირთვა"), + ("Marker", "მარკერი"), + ("Marks", "ნიშნები"), + ("Message", "შეტყობინება"), + ("Minimum height", "მინიმალური სიმაღლე"), + ("Minimum width", "მინიმალური სიგანე"), + ("Missing only", "მხოლოდ დაკარგული"), + ("Mode", "რეჟიმი"), + ("Number", "ნომერი"), + ("Object point", "ობიექტის საყრდენი წერტილი"), + ("Org", "ორგანიზაცია"), + ("Page break before", "გვერდის გამყოფი წინ"), + ("Page layout", "გვერდის განლაგება"), + ("Para style", "აბზაცის სტილი"), + ("Paragraphs", "აბზაცები"), + ("Parent", "საბაზო გვერდი"), + ("Pattern", "პატერნი"), + ("Pdf", "PDF"), + ("Pdf crop", "PDF-ის ჩამოჭრა"), + ("Pdf page", "PDF-ის გვერდი"), + ("Phone", "ტელეფონი"), + ("Pos", "პოზიცია"), + ("Prefix", "პრეფიქსი"), + ("Primary text frame", "ძირითადი ტექსტური ჩარჩო"), + ("Printer", "პრინტერი"), + ("Rasterize", "რასტრირება"), + ("Rect", "მართკუთხედი"), + ("Redefine styles", "სტილების ხელახლა განსაზღვრა"), + ("Ref", "საყრდენი წერტილი"), + ("Ref char style", "მიმართვის სიმბოლოს სტილი"), + ("Ref position", "მიმართვის პოზიცია"), + ("Remove existing", "არსებულის წაშლა"), + ("Restart", "თავიდან დაწყება"), + ("Row gutter", "სტრიქონებს შორის შუალედი"), + ("Rule", "წესი"), + ("Run in", "ერთ აბზაცში"), + ("Scope", "მოქმედების არე"), + ("Section headings", "სექციის სათაურები"), + ("Separator", "გამყოფი"), + ("Shape", "ფიგურა"), + ("Space", "ფერთა სივრცე"), + ("Space after", "დაშორება შემდეგ"), + ("Space before", "დაშორება წინ"), + ("Space between", "დაშორება შორის"), + ("Span columns", "სვეტებზე გაშლა"), + ("Start", "დასაწყისი"), + ("Start at", "დაწყება"), + ("Start number", "საწყისი ნომერი"), + ("Start page", "საწყისი გვერდი"), + ("Subject", "თემა"), + ("Subpath", "ქვეტრაექტორია"), + ("Suffix", "სუფიქსი"), + ("Suggestions", "შეთავაზებები"), + ("Tagged", "ტეგირებული"), + ("Target", "სამიზნე"), + ("Threshold", "ზღვარი"), + ("Title", "დასახელება"), + ("To family", "ახალი შრიფტის ოჯახი"), + ("To spread", "სამიზნე გაშლა"), + ("To style", "ახალი სტილი"), + ("Toc title", "სარჩევის სათაური"), + ("Tolerance", "დაშვება"), + ("Topics", "თემები"), + ("Url", "URL"), + ("Use", "გამოყენება"), + ("Use minimum height", "მინიმალური სიმაღლის გამოყენება"), + ("Use minimum width", "მინიმალური სიგანის გამოყენება"), + ("Value", "მნიშვნელობა"), + ("Visible", "ხილული"), + ("Word", "სიტყვა"), + ("X offset", "X წანაცვლება"), + ("X relative", "X-ის ათვლის წერტილი"), + ("Y offset", "Y წანაცვლება"), + (" Save ", " შენახვა "), + (" Update ", " განახლება "), + // Keyboard shortcut sets (craft-keymap), docking and new commands. + ("(cycles)", "(ციკლურად)"), + ("(modified)", "(შეცვლილი)"), + ("+Bin", "+Bin"), + ("+LOC", "+LOC"), + ("A command can have at most {n} shortcuts", "ბრძანების მალსახმობების მაქსიმალური რაოდენობა: {n}"), + ("A set with this name exists", "ამ სახელის ნაკრები უკვე არსებობს"), + ("Add a shortcut to {label}", "{label}: მალსახმობის დამატება"), + ("All Commands", "ყველა ბრძანება"), + ("Also used by {label}", "ასევე იყენებს: {label}"), + ("Alt", "Alt"), + ("Application", "აპლიკაცია"), + ("Approximated", "მიახლოებით წაკითხული"), + ("Assign", "მინიჭება"), + ("Assign to Selected", "მონიშნულზე მინიჭება"), + ("Assign {chord} to {label}", "{label}: მალსახმობის მინიჭება ({chord})"), + ("Auto-Collapse Iconic Panels", "ხატულებად ჩაკეცილი პანელების ავტომატური ჩაკეცვა"), + ("Based on:", "საფუძველი:"), + ("Built-in sets cannot be changed", "ჩაშენებული ნაკრებების შეცვლა შეუძლებელია"), + ("Cancel Import", "იმპორტის გაუქმება"), + ("Cancel the new shortcut", "ახალი მალსახმობის გაუქმება"), + ("Caps Lock", "Caps Lock"), + ("Changed", "შეცვლილი"), + ("Check for Updates…", "განახლებების შემოწმება…"), + ("Choose a command…", "აირჩიეთ ბრძანება…"), + ("Click a key to see its shortcuts", "დაწკაპეთ კლავიშზე მისი მალსახმობების სანახავად"), + ("Close Other Panels in Group", "ჯგუფის სხვა პანელების დახურვა"), + ("Close panel window", "პანელის ფანჯრის დახურვა"), + ("Command", "ბრძანება"), + ("Command for {item}", "ბრძანება ელემენტისთვის „{item}“"), + ("Commands", "ბრძანებები"), + ("Conflicts", "კონფლიქტები"), + ("Context:", "კონტექსტი:"), + ("Copy Report", "ანგარიშის კოპირება"), + ("Ctrl", "Ctrl"), + ("Current shortcuts", "მიმდინარე მალსახმობები"), + ("Current shortcuts:", "მიმდინარე მალსახმობები:"), + ("Currently assigned to:", "ამჟამად მინიჭებულია:"), + ("Default: {chord}", "ნაგულისხმევი: {chord}"), + ("Delete Parent", "საბაზო გვერდის წაშლა"), + ("Delete Surrounding Text", "მიმდებარე ტექსტის წაშლა"), + ("Delete the set “{set}”?", "ნაკრები „{set}“ წაიშალოს?"), + ("Deselect Guides", "დამხმარე ხაზების მონიშვნის მოხსნა"), + ("Detail pane size", "დეტალების არის ზომა"), + ("EPUB Split Document", "EPUB: დოკუმენტის დაყოფა"), + ("Expand", "გაშლა"), + ("Expand Panels", "პანელების გაშლა"), + ("Export…", "ექსპორტი…"), + ("File Info", "ფაილის ინფორმაცია"), + ("File Info…", "ფაილის ინფორმაცია…"), + ("Filter:", "ფილტრი:"), + ("Fixed Keys", "ფიქსირებული კლავიშები"), + ("Float Tab Group", "მცურავი ჩანართების ჯგუფი"), + ("Go to Conflict", "კონფლიქტზე გადასვლა"), + ("Group Tab Group with", "ჩანართების ჯგუფის ერთად დაჯგუფება"), + ("Import", "იმპორტი"), + ("Import InDesign Shortcut Set", "InDesign-ის მალსახმობების ნაკრების იმპორტი"), + ("Import as New Set", "იმპორტი ახალ ნაკრებად"), + ("Import “{set}”", "იმპორტი: „{set}“"), + ("Imported", "იმპორტირებული"), + ("Imported as “{set}”", "იმპორტირებულია როგორც „{set}“"), + ("Import…", "იმპორტი…"), + ("Keep Both", "ორივეს შენარჩუნება"), + ("Key: {key}", "კლავიში: {key}"), + ("Keyboard", "კლავიატურა"), + ("Keyboard Shortcuts (JSON)", "კლავიატურის მალსახმობები (JSON)"), + ("Keyboard: US", "კლავიატურა: US"), + ("Keyboard: US (macOS)", "კლავიატურა: US (macOS)"), + ("Lock Workspace", "სამუშაო სივრცის ჩაკეტვა"), + ("Matched without change", "დაემთხვა ცვლილების გარეშე"), + ("Maximize Frame", "ჩარჩოს გაშლა"), + ("Modifier on", "მოდიფიკატორი ჩართულია"), + ("More", "მეტი"), + ("Move Guides", "დამხმარე ხაზების გადაადგილება"), + ("Move Panel to New Window", "პანელის ახალ ფანჯარაში გადატანა"), + ("Move panel column", "პანელების სვეტის გადაადგილება"), + ("Move panel window", "პანელის ფანჯრის გადაადგილება"), + ("Move tab group", "ჩანართების ჯგუფის გადაადგილება"), + ("New Set…", "ახალი ნაკრები…"), + ("New shortcut:", "ახალი მალსახმობი:"), + ("New shortcut: {chord}", "ახალი მალსახმობი: {chord}"), + ("Not assigned", "არ არის მინიჭებული"), + ("Not changed", "არ შეცვლილა"), + ("Not matched", "შესაბამისობა ვერ მოიძებნა"), + ("Nothing uses this key", "ამ კლავიშს არაფერი იყენებს"), + ("Now used by {label}", "ახლა იყენებს: {label}"), + ("Option", "Option"), + ("PDF/X-1a:2003", "PDF/X-1a:2003"), + ("PRs", "PR-ები"), + ("Panel or context", "პანელი ან კონტექსტი"), + ("Remove {chord} from {label}", "{label}: მალსახმობის წაშლა ({chord})"), + ("Rename…", "სახელის შეცვლა…"), + ("Reset All", "ყველაფრის ჩამოყრა"), + ("Reset All…", "ყველაფრის ჩამოყრა…"), + ("Restore Frame Size", "ჩარჩოს ზომის აღდგენა"), + ("Restore every default shortcut in this set?", "აღდგეს ამ ნაკრების ყველა ნაგულისხმევი მალსახმობი?"), + ("Restore the default for {label}", "{label}: ნაგულისხმევის აღდგენა"), + ("Revert Set", "ნაკრების წინა ვერსიაზე დაბრუნება"), + ("Ruler Zero Point", "სახაზავის ნულოვანი წერტილი"), + ("Save changes to “{set}”?", "შეინახოს ცვლილებები ნაკრებში „{set}“?"), + ("Search by key", "ძიება კლავიშით"), + ("Select Guide", "დამხმარე ხაზის მონიშვნა"), + ("Select a command first", "ჯერ მონიშნეთ ბრძანება"), + ("Select a command to see and change its shortcuts", "მონიშნეთ ბრძანება მისი მალსახმობების სანახავად და შესაცვლელად"), + ("Set name:", "ნაკრების სახელი:"), + ("Set:", "ნაკრები:"), + ("Shift", "Shift"), + ("Shortcut", "მალსახმობი"), + ("Shortcut Sets", "მალსახმობების ნაკრებები"), + ("Show:", "ჩვენება:"), + ("Skipped", "გამოტოვებული"), + ("Story Options", "ტექსტური ნაკადის პარამეტრები"), + ("Suggestions ({n})", "შეთავაზებები ({n})"), + ("Summarize…", "შეჯამება…"), + ("Super", "Super"), + ("Take It", "აღება"), + ("Take It and Go to Conflict", "აღება და კონფლიქტზე გადასვლა"), + ("Taken from other commands", "სხვა ბრძანებებიდან აღებული"), + ("The file is too large", "ფაილი ძალიან დიდია"), + ("The name is empty, too long or has control characters", "სახელი ცარიელია, ძალიან გრძელია ან მმართველ სიმბოლოებს შეიცავს"), + ("The scope {scope} is unknown", "მოქმედების არე {scope} უცნობია"), + ("The set was not found", "ნაკრები ვერ მოიძებნა"), + ("There are too many sets", "ნაკრები ძალიან ბევრია"), + ("This command is unknown", "ეს ბრძანება უცნობია"), + ("This file is from a newer version (format {n})", "ეს ფაილი უფრო ახალი ვერსიისაა (ფორმატი {n})"), + ("This is not a craft-keymap file", "ეს craft-keymap ფაილი არ არის"), + ("This is not a shortcut set file", "ეს მალსახმობების ნაკრების ფაილი არ არის"), + ("Too many shortcuts are assigned", "მინიჭებულია ძალიან ბევრი მალსახმობი"), + ("Tools", "ხელსაწყოები"), + ("Transparency Flattener", "გამჭვირვალობის გაბრტყელება"), + ("Type…", "ტექსტი…"), + ("Unreadable", "წაუკითხავი"), + ("Use Default", "ნაგულისხმევის გამოყენება"), + ("Will change", "შეიცვლება"), + ("Win", "Win"), + ("With Shortcut", "მალსახმობის მქონე"), + ("empty shortcut", "ცარიელი მალსახმობი"), + ("in a context that is not imported", "კონტექსტში, რომელიც არ იმპორტირდება"), + ("key sequences are not supported", "კლავიშთა მიმდევრობები მხარდაჭერილი არ არის"), + ("line {n}", "სტრიქონი {n}"), + ("modifier {key} given twice", "მოდიფიკატორი {key} ორჯერაა მითითებული"), + ("more than one command fits", "ერთზე მეტი ბრძანება შეესაბამება"), + ("no command has this name", "ამ სახელის ბრძანება არ არსებობს"), + ("numeric keypad key read as main key", "ციფრული ბლოკის კლავიში წაკითხულია ძირითად კლავიშად"), + ("only a command of the other kind fits (tool or menu command)", "შეესაბამება მხოლოდ სხვა სახეობის ბრძანება (ხელსაწყო ან მენიუს ბრძანება)"), + ("press keys", "დააჭირეთ კლავიშებს"), + ("reserved by the system", "სისტემისთვისაა დაჯავშნული"), + ("taken by a later entry", "დაიკავა მომდევნო ჩანაწერმა"), + ("taken by {label}", "დაიკავა: {label}"), + ("the scope is unknown", "მოქმედების არე უცნობია"), + ("the scope name is not valid", "მოქმედების არის სახელი არასწორია"), + ("the set's shortcuts could not be added", "ნაკრების მალსახმობების დამატება ვერ მოხერხდა"), + ("the shortcut has no key", "მალსახმობს კლავიში არ აქვს"), + ("the shortcut is too long", "მალსახმობი ძალიან გრძელია"), + ("the shortcuts could not be removed", "მალსახმობების წაშლა ვერ მოხერხდა"), + ("unknown key {key}", "უცნობი კლავიში {key}"), + ("unknown modifier {key}", "უცნობი მოდიფიკატორი {key}"), + ("unsupported modifier {key}", "მხარდაუჭერელი მოდიფიკატორი {key}"), + ("{chord} (Default)", "{chord} (ნაგულისხმევი)"), + ("{chord} (press again for a double press)", "{chord} (ორმაგი დაჭერისთვის კვლავ დააჭირეთ)"), + ("{chord} ({scope})", "{chord} ({scope})"), + ("{chord} is reserved by the system", "{chord} სისტემისთვისაა დაჯავშნული"), + ("{chord} was removed from {label}", "{label}: მალსახმობი {chord} წაიშალა"), + ("{chord}: {label}", "{chord}: {label}"), + ("{key}: listed more than once", "{key}: ერთზე მეტჯერაა მითითებული"), + ("{key}: no command has this name", "{key}: ამ სახელის ბრძანება არ არსებობს"), + ("{key}: part of the entry was dropped", "{key}: ჩანაწერის ნაწილი გამოტოვებულია"), + ("{key}: the scope in “{text}” is unknown", "{key}: მოქმედების არე უცნობია („{text}“)"), + ("{key}: {text} is reserved by the system", "{key}: {text} სისტემისთვისაა დაჯავშნული"), + ("{key}: “{text}” is not a shortcut ({error})", "{key}: „{text}“ მალსახმობი არ არის ({error})"), + ("{label} (Changed)", "{label} (შეცვლილი)"), + ("{label} (cycles)", "{label} (ციკლურად)"), + ("{label} ({scope})", "{label} ({scope})"), + ("{label} ({scope}, both work)", "{label} ({scope}, ორივე მუშაობს)"), + ("{label} keeps its shortcuts (matched by a panel menu name)", "{label} მალსახმობებს ინარჩუნებს (დაემთხვა პანელის მენიუს სახელით)"), + ("{label}: press keys", "{label}: დააჭირეთ კლავიშებს"), + ("{label}: {chord}", "{label}: {chord}"), + ("{label}: {chord} (Now used by {holder})", "{label}: {chord} (ახლა იყენებს: {holder})"), + ("{menu} Menu", "მენიუ „{menu}“"), + ("{n} command changed", "შეცვლილი ბრძანებები: {n}"), + ("{n} command will change", "შესაცვლელი ბრძანებები: {n}"), + ("{n} commands changed", "შეცვლილი ბრძანებები: {n}"), + ("{n} commands will change", "შესაცვლელი ბრძანებები: {n}"), + ("{n} entries cut at a limit", "ლიმიტზე შეკვეცილი ჩანაწერები: {n}"), + ("{n} entries were ignored: too many commands", "უგულებელყოფილი ჩანაწერები (ბრძანება ძალიან ბევრია): {n}"), + ("{n} entry cut at a limit", "ლიმიტზე შეკვეცილი ჩანაწერები: {n}"), + ("{n} entry was ignored: too many commands", "უგულებელყოფილი ჩანაწერები (ბრძანება ძალიან ბევრია): {n}"), + ("{n} item could not be read", "ელემენტები, რომელთა წაკითხვა ვერ მოხერხდა: {n}"), + ("{n} item not matched", "ელემენტები შესაბამისობის გარეშე: {n}"), + ("{n} item without a shortcut", "ელემენტები მალსახმობის გარეშე: {n}"), + ("{n} items could not be read", "ელემენტები, რომელთა წაკითხვა ვერ მოხერხდა: {n}"), + ("{n} items not matched", "ელემენტები შესაბამისობის გარეშე: {n}"), + ("{n} items without a shortcut", "ელემენტები მალსახმობის გარეშე: {n}"), + ("{n} key read approximately", "მიახლოებით წაკითხული კლავიშები: {n}"), + ("{n} keys read approximately", "მიახლოებით წაკითხული კლავიშები: {n}"), + ("{n} saved shortcut could not be used", "შენახული მალსახმობები, რომელთა გამოყენება ვერ მოხერხდა: {n}"), + ("{n} saved shortcuts could not be used", "შენახული მალსახმობები, რომელთა გამოყენება ვერ მოხერხდა: {n}"), + ("{n} shortcut also works in another scope", "სხვა მოქმედების არეშიც მოქმედი მალსახმობები: {n}"), + ("{n} shortcut is assigned more than once", "ერთზე მეტჯერ მინიჭებული მალსახმობები: {n}"), + ("{n} shortcuts also work in another scope", "სხვა მოქმედების არეშიც მოქმედი მალსახმობები: {n}"), + ("{n} shortcuts are assigned more than once", "ერთზე მეტჯერ მინიჭებული მალსახმობები: {n}"), + ("{panel} Panel", "პანელი „{panel}“"), + ("{title} ({n})", "{title} ({n})"), + ("{title}: {set}", "{title}: {set}"), + ("ΔLOC", "ΔLOC"), + ("−Bin", "−Bin"), + ("−LOC", "−LOC"), + ("⚠ {chord} is used by {label}", "⚠ {chord} დაკავებულია: {label}"), + ("Y:", "Y:"), + ("Show the Typography panel", "ტიპოგრაფიის პანელის ჩვენება"), + ( + "This story was edited in the layout: you'll be asked before those edits are replaced", + "ეს ტექსტური ნაკადი განლაგებაში რედაქტირებულია: ამ ცვლილებების ჩანაცვლებამდე დაგეკითხებით", + ), ]; From 20d6722820222ed91992ffe8511c48663697db05 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 28 Oct 2026 05:00:07 +0400 Subject: [PATCH 20/48] i18n(ka): keep the Letter, Legal and Letter - Half paper sizes in English, like Tabloid Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 1cefbbfcf..14a9e6203 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -2503,10 +2503,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Stylistic Alternates", "სტილისტური ალტერნატივები"), ("Justification Alternates", "გასწორების ალტერნატივები"), ("Roman Italics", "რომაული დახრილი"), - ("Letter", "წერილი"), - ("Legal", "იურიდიული ინფორმაცია"), + ("Letter", "Letter"), + ("Legal", "Legal"), ("Tabloid", "Tabloid"), - ("Letter - Half", "წერილი - ნახევარი"), + ("Letter - Half", "Letter - Half"), ("Postcard", "ღია ბარათი"), ("Web 1920 × 1080", "ვები 1920 × 1080"), ("Web 1366 × 768", "ვები 1366 × 768"), From 87859340324746cedae57da9c1692b7cb564781e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:50:17 +0000 Subject: [PATCH 21/48] Open documents whose items share an id Fixes #1883 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 82 +++++++++++++++++++++++++++++++++++ crates/format/src/lib.rs | 2 + crates/format/src/tests.rs | 88 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bf9998209..08ba34ca0 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -955,6 +955,88 @@ impl Document { designcraft_color::swatch::resolve(&self.swatches, swatch, tint) } + /// Give items that reuse an earlier item's id (spreads, then parents, then items anchored in + /// stories) fresh ids, so a file holding such duplicates opens. The first item keeps the id and + /// what refers to it by id; a renumbered text frame takes its place in its story's thread. + pub fn renumber_duplicate_items(&mut self) { + fn walk_mut(it: &mut Item, f: &mut impl FnMut(&mut Item)) { + f(it); + for c in it.children_mut().into_iter().flatten() { + walk_mut(Arc::make_mut(c), f); + } + } + fn story_items(st: &mut Story, f: &mut impl FnMut(&mut Item)) { + for o in &mut st.objects { + walk_mut(&mut Arc::make_mut(o).item, f); + } + for t in st.tables.values_mut() { + for c in &mut Arc::make_mut(t).cells { + story_items(&mut c.text, f); + } + } + for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + story_items(&mut Arc::make_mut(n).text, f); + } + } + let mut seen = std::collections::HashSet::new(); + let mut dups = std::collections::HashSet::new(); + let mut next = self.next_id; + let mut note = |id: ItemId| { + next = next.max(id.0); + if !seen.insert(id) { + dups.insert(id); + } + }; + for sp in self.spreads.iter().chain(self.parents.iter()) { + for it in &sp.items { + it.walk(&mut |i| note(i.id)); + } + } + for (_, i) in self.anchored_items() { + note(i.id); + } + if dups.is_empty() { + return; + } + let mut seen = std::collections::HashSet::new(); + // (story, id in the file) → the ids its frames carry now, in walk order. + let mut threads: std::collections::HashMap<(StoryId, ItemId), Vec> = std::collections::HashMap::new(); + let mut fix = |it: &mut Item| { + let old = it.id; + if !dups.contains(&old) { + return; + } + if !seen.insert(old) { + next += 1; + it.id = ItemId(next); + } + if let Some(t) = it.text_frame() { + threads.entry((t.story, old)).or_default().push(it.id); + } + }; + for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) { + for it in &mut Arc::make_mut(sp).items { + walk_mut(Arc::make_mut(it), &mut fix); + } + } + for st in self.stories.values_mut() { + story_items(Arc::make_mut(st), &mut fix); + } + self.next_id = next; + for ((sid, old), ids) in threads { + let Some(st) = self.stories.get_mut(&sid) else { continue }; + let st = Arc::make_mut(st); + let mut ids = ids.into_iter(); + for f in st.frames.iter_mut().filter(|f| **f == old) { + match ids.next() { + Some(id) => *f = id, + None => break, + } + } + st.frames.extend(ids); + } + } + /// Validate structural invariants (ids unique, threads consistent, stories well-formed). pub fn check(&self) -> Result<()> { for f in &self.styles.composite_fonts { diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1adc43449..15156f8ad 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -251,6 +251,7 @@ fn load_zip(bytes: &[u8]) -> Result { upgrade(&mut doc, u32::try_from(version).unwrap_or(u32::MAX)); doc.merge_duplicate_assets(); doc.mark_differing_margins_local(); + doc.renumber_duplicate_items(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } @@ -318,6 +319,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result { upgrade(&mut doc, 1); doc.merge_duplicate_assets(); doc.mark_differing_margins_local(); + doc.renumber_duplicate_items(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index 981898a45..702428d14 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -734,3 +734,91 @@ fn too_many_entries_are_refused() { let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an archive with 100 001 entries loaded") }; assert!(err.to_string().contains("entries"), "{err}"); } + +/// Every item id in `d` (spreads, parents, anchored), in walk order. +fn all_item_ids(d: &Document) -> Vec { + let mut ids = Vec::new(); + for sp in d.spreads.iter().chain(d.parents.iter()) { + for it in &sp.items { + it.walk(&mut |i| ids.push(i.id.0)); + } + } + ids.extend(d.anchored_items().iter().map(|(_, i)| i.id.0)); + ids +} + +fn set_item_id(d: &mut Document, index: usize, id: designcraft_doc::ItemId) { + let sp = Arc::make_mut(&mut d.spreads[0]); + Arc::make_mut(&mut sp.items[index]).id = id; +} + +#[test] +fn files_with_duplicate_item_ids_open_with_unique_ids() { + let mut d = Document::new(&NewDocument::default()); + let one = png_asset(&mut d, &[1]); + let two = png_asset(&mut d, &[2]); + let a = place(&mut d, one, 0.0); + place(&mut d, two, 0.0); + set_item_id(&mut d, 1, a); + let back = load(&save(&d).unwrap()).unwrap(); + back.check().unwrap(); + let ids = all_item_ids(&back); + assert_eq!(ids.len(), 2); + assert_ne!(ids[0], ids[1]); + assert!(back.next_id >= ids[0].max(ids[1])); + let assets: Vec<_> = back.spreads[0].items.iter().map(|i| i.graphic().unwrap().asset).collect(); + assert_eq!(assets, vec![one, two]); + assert_eq!(back.item(a).and_then(|i| i.graphic()).map(|g| g.asset), Some(one)); +} + +#[test] +fn duplicate_text_frame_ids_keep_each_story_in_its_own_frame() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "first", ParaFormat::default()).unwrap(); + let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 210.0, 200.0, 400.0), lid, "second", ParaFormat::default()).unwrap(); + let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 210.0, 400.0, 400.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(b, c).unwrap(); + // The second story's first frame carries the first frame's id. + set_item_id(&mut d, 1, a); + let st = d.story_mut(sb).unwrap(); + assert_eq!(st.frames, vec![b, c]); + st.frames[0] = a; + let back = load(&save(&d).unwrap()).unwrap(); + back.check().unwrap(); + let ids = all_item_ids(&back); + let unique: HashSet<_> = ids.iter().collect(); + assert_eq!(unique.len(), 3); + assert_eq!(back.stories[&sa].frames, vec![a]); + assert_eq!(back.stories[&sa].text, "first"); + let frames = &back.stories[&sb].frames; + assert_eq!(frames.len(), 2); + assert_ne!(frames[0], a); + assert_eq!(frames[1], c); + assert_eq!(back.item(frames[0]).and_then(|i| i.text_frame()).map(|t| t.story), Some(sb)); + assert_eq!(back.stories[&sb].text, "second"); +} + +#[test] +fn duplicate_anchored_item_ids_are_renumbered() { + use designcraft_doc::anchored::{AnchorPosition, AnchoredObject}; + use designcraft_doc::geom::shapes; + use designcraft_doc::{Item, Shape}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "ab", ParaFormat::default()).unwrap(); + let asset = png_asset(&mut d, &[1]); + let g = place(&mut d, asset, 0.0); + let item = Item::new(g, lid, Shape::Oval, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::default())); + let back = load(&save(&d).unwrap()).unwrap(); + back.check().unwrap(); + let ids = all_item_ids(&back); + assert_eq!(ids.len(), 3); + assert_eq!(ids.iter().collect::>().len(), 3); + assert_eq!(back.item(g).and_then(|i| i.graphic()).map(|g| g.asset), Some(asset)); + let anchored = back.anchored_items(); + assert_eq!(anchored.len(), 1); + assert_eq!(anchored[0].1.shape, Shape::Oval); + assert_ne!(anchored[0].1.id, g); +} From a30744fed11f45e1e80afbf822c436a7b71c731d Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 11:53:56 +0500 Subject: [PATCH 22/48] Drop the unused ImageCache inspection methods `len` and `is_empty` were added for a test that ended up counting reads through `io::touched` instead, so they were dead code -- and the gate runs `cargo clippy --workspace --all-targets -- -D warnings`, which turns `dead_code` into a failure. Hosted CI was red on this PR. My local check had been `cargo clippy -p designcraft-engine --all-targets` without `-D warnings`, which is why I missed it; re-ran it the way the gate does. Found by the review bot, confirmed against the CI log. --- crates/engine/src/cmd/datamerge/fill.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 965e87bd7..9b0a07c24 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -172,15 +172,6 @@ impl ImageCache { } Some(bytes) } - - /// How many distinct files this merge has read, for the tests. - pub fn len(&self) -> usize { - self.inner.borrow().files.len() - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } } /// Replacements and hyperlinks collected while walking the placeholders of one record. From 4ac4219fd24361fc240382a479a7171007b45a70 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 10:23:57 +0000 Subject: [PATCH 23/48] compose: settle a story's own anchored wraps by object count, not 4 passes A story whose custom or above-line anchored objects wrap its own later lines is composed first without those wraps, then again with each object where the previous pass placed it. That loop stopped after a fixed MAX_PASSES = 4 extra compositions. Each object's wrap pushes down the anchors of the objects after it, so in a chain of such objects the k-th object only lands where it belongs once the k - 1 wraps before it are in place. From roughly the fifth chained object on, the loop ran out before the story settled: the final composition placed the object in one spot but wrapped the text around where an earlier pass had put it, and lines printed straight over the object. Bound the loop by the story's own wrapping objects instead: n + 1 passes for n such objects (inline objects never wrap their own story and are left out). Composition 0 has no own wraps, so object 1 is already where it belongs; by induction pass k places object k + 1 correctly, pass n places the last one, and pass n + 1 wraps the text around it, after which the positions cannot move. The existing settled check still stops a story early as soon as its objects stop moving, so stories that settle sooner cost nothing extra. MAX_PASSES stays as an absolute safety net, raised to 64, for layouts that keep moving their objects (keep options, vertical justification) and for stories with very many anchored objects. Test: chained_anchored_objects_all_wrap_their_own_storys_lines anchors eight 100 x 60 pt bounding-box objects six lines apart in one story, each hanging from its anchor line, and checks that every object sits on its anchor line and every line it hangs over moves clear of it. Without this change it fails at the seventh object (a line 790 pt down sits at x 0 inside the object's band at 718 pt); with it, it passes. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- crates/compose/src/tests.rs | 38 +++++++++++++++++++++++++++++++++++++ crates/compose/src/wrap.rs | 15 ++++++++++----- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3083261fb..aafef67e3 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7155,6 +7155,44 @@ fn anchored_object_wraps_its_own_storys_later_lines() { assert!((o.origin.y - top).abs() < 1e-6); } +/// Each object's wrap pushes the anchors after it down, so where each object lands depends on the +/// wraps of all those before it; every object still ends up wrapping the lines it hangs over. +#[test] +fn chained_anchored_objects_all_wrap_their_own_storys_lines() { + const OBJECTS: usize = 8; + let text = [LOREM; 12].join(" "); + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 3000.0), ParaAttrs::default()); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&plain); + assert!(lines.len() > 2 + 6 * OBJECTS); + let left = lines[0].x0; + // One object every six lines, each hanging from its anchor's baseline over the lines below; + // inserted last first so the earlier offsets still hold. + let ats: Vec = (0..OBJECTS).map(|k| lines[2 + 6 * k].range.start + 3).collect(); + for &at in ats.iter().rev() { + anchor_box(&mut d, sid, at, 0.0, 0.0, true, WrapMode::BoundingBox); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let lines = all_lines(&cs); + let anchors: Vec = d.story(sid).unwrap().text.match_indices(designcraft_doc::OBJECT_MARK).map(|(i, _)| i).collect(); + let objects = &cs.frames[0].objects; + assert_eq!(objects.len(), OBJECTS); + for o in objects { + let anchor_line = lines.iter().find(|l| l.range.contains(&anchors[o.index])).unwrap(); + assert!((o.origin.y - anchor_line.baseline).abs() < 1e-6, "object {} at {}, anchor line at {}", o.index, o.origin.y, anchor_line.baseline); + let under = lines_in_band(&cs, o.origin.y, o.origin.y + 60.0); + assert!(under.len() >= 2); + assert!( + under.iter().all(|l| l.x0 >= left + 100.0 - 1e-6), + "object {} at {}: {:?}", + o.index, + o.origin.y, + under.iter().map(|l| (l.baseline, l.x0)).collect::>() + ); + } +} + #[test] fn anchored_object_without_wrap_leaves_layout_unchanged() { let long = [LOREM; 3].join(" "); diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index e1cdc5fa7..7d38f0ef8 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -12,16 +12,20 @@ //! that come after the line holding its anchor; that line and the lines before it are never //! wrapped by it (an inline object sits in its line and wraps none of its own story). The story //! is composed again with the objects where the previous pass placed them until they stop -//! moving, at most [`MAX_PASSES`] more times. +//! moving. An object's wrap can push the anchors of the objects after it, so a chain of n such +//! objects needs n + 1 more compositions: the k-th pass places the (k + 1)-th object where the +//! wraps before it leave its anchor, and the last pass wraps around the last object. The story +//! is composed at most that many more times, and never more than [`MAX_PASSES`]. use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId, WrapMode}; use designcraft_geom::Affine; use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec}; -/// Extra compositions of a story with its own anchored objects' wrap; a layout that keeps moving -/// its objects (keep options, vertical justification) stops here. -pub(crate) const MAX_PASSES: usize = 4; +/// Most extra compositions of a story with its own anchored objects' wrap, however many objects +/// it has; a layout that keeps moving its objects (keep options, vertical justification) stops +/// sooner, after one per object and one more. +pub(crate) const MAX_PASSES: usize = 64; /// How far an obstacle may move between passes and still count as settled (points). const SETTLED: f64 = 0.01; @@ -199,8 +203,9 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, if !has_wrapping_objects(st) { return out; } + let own = st.objects.iter().filter(|o| wraps(o) && !matches!(o.position, AnchorPosition::Inline { .. })).count(); let mut prev = vec![Vec::new(); specs.len()]; - for _ in 0..MAX_PASSES { + for _ in 0..(own + 1).min(MAX_PASSES) { let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true)); if settled(&added, &prev) { break; From ff35b3561fab711c9580ffd7fdf146779eac7ea1 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 15:33:12 +0500 Subject: [PATCH 24/48] Data merge: an extended-length drive path is a local file, not a refusal Review caught a wrong assertion in my own test. `\\?\C:\x` is the extended-length spelling of `C:\x`: craft-docpath parses it as Root::VerbatimDrive, is_absolute, and its own suite classifies it PathKind::Local. The test asserted it was refused, so it would have failed on Windows -- the one platform it runs on. A data file can already name C:\secret\x.png, so refusing the long spelling would have shut out long paths without shutting anything out. It is now asserted the other way, and the refusal list keeps a device path in its place. Each shape was checked against the pinned craft-docpath through resolve(), which recognises Windows forms on every platform, so the verdicts are first-hand and not read off the source: Network for the extended UNC prefix, Device for \\.\pipe, GLOBALROOT, CON and NUL.png, and Ok for \\?\C:\secret\x.png. --- crates/engine/src/cmd/datamerge/fill.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index f522065ba..5b9a5a92d 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -545,16 +545,28 @@ mod image_path_tests { } /// The Windows shapes `craft-docpath` refuses, asserted where they have meaning: the extended - /// UNC prefix, a device namespace path, and a legacy device name. + /// UNC prefix, a device namespace path, and a legacy device name (with an extension, since a + /// reserved name is reserved whatever follows it). #[cfg(windows)] #[test] - fn windows_device_and_extended_paths_are_refused() { + fn windows_device_and_network_paths_are_refused() { let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc")); - for cell in [r"\\?\UNC\192.0.2.1\share\x.png", r"\\.\pipe\x", r"\\?\C:\secret\x.png", "CON", "NUL.png"] { + for cell in [r"\\?\UNC\192.0.2.1\share\x.png", r"\\.\pipe\x", r"\\?\GLOBALROOT\Device\HarddiskVolume1\x", "CON", "NUL.png"] { assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}"); } } + /// An extended-length path is not one of them: `\\?\C:\x` names a local file, the same one + /// `C:\x` names, and `craft-docpath`'s own suite classifies it `PathKind::Local`. A data file + /// could already give the plain spelling, so refusing this one would shut out long paths + /// without shutting anything out (noted in review). + #[cfg(windows)] + #[test] + fn an_extended_length_drive_path_is_a_local_file() { + let (data, doc) = (Path::new(r"C:\work\data"), Path::new(r"C:\work\doc")); + assert!(!image_candidates(r"\\?\C:\secret\x.png", Some(data), Some(doc)).is_empty()); + } + /// Resolution beside the data and the document, with absolute paths this platform means. #[cfg(unix)] #[test] From b49535624c6b6b6a6ca87f35c207a9a2ddb66951 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 16:26:13 +0500 Subject: [PATCH 25/48] Tables: a shape a table cannot hold is clamped, not allocated (#1583) `Table::new` allocates `rows * cols` cells up front, and the counts reach it from IDML and DOCX, where they are whatever the file claims. Measured here: `size_of::()` is 1,360 bytes, so the reported IDML case -- one `` with 5,000 `` and 5,000 ``, about 1 KB zipped -- asks for 25M cells, some 34 GB. That allocation does not fail politely: it aborts the process through `handle_alloc_error`, which no `catch_unwind` can catch, so every open document's unsaved work goes with it. A table is clamped to InDesign's own limits, 10,000 rows and 1,000 columns, and to 250,000 cells whatever its shape -- the row and column limits alone would still allow 13.6 GB. Columns are clamped first and rows take what is left, because a table is far more often long than wide. The IDML importer walks the clamped slices and reports the difference. Indexing its rows and columns by the file's own counts would be out of bounds, which is how a 34 GB abort would otherwise have become a panic. Rebased onto the post-split main: the constants and `clamped_size` live in `doc`'s `table` module root, `Table::new` in `table/grid.rs`, and the IDML table reader in `import/table.rs`. --- crates/doc/src/table.rs | 68 +++++++++++++++++++++++++++++++++ crates/doc/src/table/grid.rs | 10 ++++- crates/idml/src/import/table.rs | 17 ++++++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index d5342b5c9..937fefe1f 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -25,6 +25,32 @@ mod story; pub use cell::{AltFills, AltStrokes, Cell, CellStroke, Column, Row, RowHeightMode, RowKind, TableOptions}; pub use grid::CellRange; +/// Most rows a table holds, as InDesign allows. +pub const MAX_TABLE_ROWS: usize = 10_000; +/// Most columns a table holds, as InDesign allows. +pub const MAX_TABLE_COLS: usize = 1_000; +/// Most cells a table holds, whatever its shape. +/// +/// A [`Cell`] is about 1.4 KB (it carries a [`Story`]), so this is roughly 340 MB of cells at the +/// limit. The row and column limits alone are not enough: 10,000 × 1,000 would be 13.6 GB, and an +/// IDML story holding one `
` with 5,000 `` and 5,000 `` -- about 1 KB +/// zipped -- asked for 25M cells, some 34 GB. That allocation does not fail politely: it aborts +/// the process through `handle_alloc_error`, which no `catch_unwind` can save, so every open +/// document's unsaved work goes with it (#1583). +pub const MAX_TABLE_CELLS: usize = 250_000; + +/// The rows and columns a table of `rows × cols` is actually given, inside the limits above. +/// +/// Columns are clamped first and rows take what is left of the cell budget, because a table is +/// far more often long than wide: truncating a 5,000-column table to 1,000 columns and then its +/// rows loses less of it than the other way round. Callers that read a file report the +/// difference; the counts are a document's claim, not something to trust. +pub fn clamped_size(rows: usize, cols: usize) -> (usize, usize) { + let cols = cols.clamp(1, MAX_TABLE_COLS); + let rows = rows.clamp(1, MAX_TABLE_ROWS.min(MAX_TABLE_CELLS / cols).max(1)); + (rows, cols) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Table { @@ -48,6 +74,48 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + /// A table's shape comes out of IDML and DOCX files unchecked, and `Cell` is about 1.4 KB, so + /// a file claiming 5,000 x 5,000 asked for 25M cells -- some 34 GB. That allocation aborts the + /// process rather than failing, taking every open document's unsaved work with it (#1583). + #[test] + fn a_table_larger_than_the_limits_is_clamped_not_allocated() { + // The reported shape: 5,000 rows and 5,000 columns. + let t = Table::new(1, 5_000, 5_000, 0, 0, 500.0); + assert_eq!(t.columns.len(), MAX_TABLE_COLS, "columns are clamped to InDesign's limit"); + assert!(t.cells.len() <= MAX_TABLE_CELLS, "{} cells is within the budget", t.cells.len()); + assert_eq!(t.rows.len() * t.columns.len(), t.cells.len(), "the grid stays rectangular"); + // What it would have been: 25,000,000 cells at 1,360 bytes each. + assert!(25_000_000 * std::mem::size_of::() > 30_000_000_000, "the size this test is about"); + // Freed before the next large table, so the peak stays at one of them. + drop(t); + + // A long table keeps its rows while it fits the budget (the budget itself is checked by + // `clamped_size`, which allocates nothing). + let long = Table::new(2, 10_000, 5, 0, 0, 500.0); + assert_eq!((long.rows.len(), long.columns.len()), (10_000, 5), "a long thin table is untouched"); + assert_eq!(clamped_size(10_000, 25), (10_000, 25), "10,000 x 25 is inside the budget"); + drop(long); + + // Past the row limit the rows are clamped too. + let longer = Table::new(3, 50_000, 2, 0, 0, 100.0); + assert_eq!(longer.rows.len(), MAX_TABLE_ROWS, "rows are clamped to InDesign's limit"); + drop(longer); + + // Header, body and footer share the budget, and the table still has a body. + let with_parts = Table::new(4, 5_000, 5_000, 3, 2, 500.0); + assert!(with_parts.rows.len() <= MAX_TABLE_ROWS, "{} rows", with_parts.rows.len()); + assert!(with_parts.cells.len() <= MAX_TABLE_CELLS, "{} cells", with_parts.cells.len()); + assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Header), "the header survives"); + assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Footer), "the footer survives"); + assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Body), "and so does a body row"); + drop(with_parts); + + // A header and footer bigger than the whole budget still leave a body row. + let all_header = Table::new(6, 1, 1, MAX_TABLE_ROWS * 2, MAX_TABLE_ROWS * 2, 100.0); + assert!(all_header.rows.len() <= MAX_TABLE_ROWS, "{} rows", all_header.rows.len()); + assert!(all_header.rows.iter().any(|r| r.kind == RowKind::Body), "a body row survives"); + } + #[test] fn move_row_keeps_header_and_footer_rows_in_place() { let mut t = Table::new(1, 3, 2, 1, 1, 120.0); diff --git a/crates/doc/src/table/grid.rs b/crates/doc/src/table/grid.rs index 48647cffc..f93379231 100644 --- a/crates/doc/src/table/grid.rs +++ b/crates/doc/src/table/grid.rs @@ -25,8 +25,16 @@ impl CellRange { impl Table { /// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`. + /// + /// The shape is clamped to [`clamped_size`](super::clamped_size): the counts reach this from + /// IDML and DOCX files, where they are whatever the file says (#1583). pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table { - let cols = cols.max(1); + // Header, body and footer rows share one budget: the total is clamped first, then the + // header and footer take what they can of it, and the body keeps at least one row. + let (total, cols) = super::clamped_size(body_rows.max(1).saturating_add(header).saturating_add(footer), cols); + let header = header.min(total.saturating_sub(1)); + let footer = footer.min(total.saturating_sub(header).saturating_sub(1)); + let body_rows = total.saturating_sub(header).saturating_sub(footer).max(1); let mut rows = Vec::new(); rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() })); rows.extend((0..body_rows.max(1)).map(|_| Row::default())); diff --git a/crates/idml/src/import/table.rs b/crates/idml/src/import/table.rs index 5e5a8685d..9bf0f920f 100644 --- a/crates/idml/src/import/table.rs +++ b/crates/idml/src/import/table.rs @@ -25,8 +25,21 @@ impl<'r> Importer<'r> { let id = self.alloc(); let rows: Vec<&El> = e.find_all("Row").collect(); let cols: Vec<&El> = e.find_all("Column").collect(); - let nr = rows.len().max(1); - let nc = cols.len().max(1); + // A file's row and column counts are its claim, and a table of them is allocated whole: a + // story holding one `
` with 5,000 of each asked for 25M cells, around 34 GB, which + // aborts the process instead of failing (#1583). What is read is clamped, and the rest of + // this function walks the clamped slices -- indexing `t.rows`/`t.columns` by the file's + // own counts would be out of bounds. + let (nr, nc) = designcraft_doc::table::clamped_size(rows.len().max(1), cols.len().max(1)); + if nr < rows.len() || nc < cols.len() { + self.warnings.push(format!( + "a table of {} rows and {} columns was read as {nr} x {nc}: more than a table can hold", + rows.len(), + cols.len() + )); + } + let rows = &rows[..nr.min(rows.len())]; + let cols = &cols[..nc.min(cols.len())]; let mut t = Table::new(id, nr, nc, 0, 0, 0.0); t.options.direction = if e.get("TableDirection") == Some("RightToLeftDirection") { designcraft_doc::TextDirection::RightToLeft From 1995fb5feb1b46e76fe76d6cc77635c42963b092 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 16:31:34 +0500 Subject: [PATCH 26/48] IDML import: the tables in one file are capped between them Review pointed out that capping each table leaves a file free to hold many capped tables: three at the per-table limit claim 750,000 cells, about 1 GB of Cell, with every table inside its own limit. The package reader's limits count input bytes, which say nothing about how many cells a row and column count claim. budgeted_size clamps each table against what the import has left, and the cells are reserved before Table::new so a cell's own story cannot spend the budget the table around it is counting on. The budget is 400,000 cells: a Cell carries a whole story and is 1,360 bytes, so that is about the 512 MiB one part of the file may already decompress to, and the same count the spreadsheet importer collects at most. A table is never read as nothing. One row and one column are kept, so the table stays in the document and still says where its text went, and a file pays one cell for a table it really does contain -- which its own size already bounds. The warning says which limit cut the table down. The arithmetic is tested rather than the allocation: 400,000 cells is 544 MB and three such tables are not a thing to ask CI to allocate. The sequence test runs 32 claimed sizes, including usize::MAX, and holds the invariant across all of them. --- crates/idml/src/import.rs | 4 ++ crates/idml/src/import/table.rs | 91 ++++++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 73bed14d3..c8e0512b5 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -139,6 +139,9 @@ struct Importer<'r> { /// Graphic `Self` → stand-in image for a linked file that can't be read. previews: &'r HashMap>>, next_id: u64, + /// Cells every table in this import has reserved. One table is capped, but a file chooses + /// how many tables it has, so the cap has to be document-wide as well (noted in review). + table_cells: usize, settings: DocSettings, default_margins: Option<(Margins, Columns)>, swatches: Vec, @@ -225,6 +228,7 @@ impl<'r> Importer<'r> { read_link, previews, next_id: 0, + table_cells: 0, settings: DocSettings::default(), default_margins: None, swatches: specials, diff --git a/crates/idml/src/import/table.rs b/crates/idml/src/import/table.rs index 9bf0f920f..3b1051b66 100644 --- a/crates/idml/src/import/table.rs +++ b/crates/idml/src/import/table.rs @@ -1,6 +1,30 @@ use super::values::{span, tint}; use super::*; +/// Cells every table in one import may hold between them. A `Cell` carries a whole story, so +/// it is 1,360 bytes: 400,000 of them is about 520 MB, which is the 512 MiB the package reader +/// already allows one part of the file to decompress to, and the same count the spreadsheet +/// importer collects at most. +const MAX_IMPORT_TABLE_CELLS: usize = 400_000; + +/// How large a table of `rows` x `cols` may be read when `reserved` cells have already been +/// taken, and whether the file-wide budget is what cut it down. +/// +/// The per-table cap leaves a file free to hold many capped tables, so each is also clamped +/// against what is left. One row and one column are always kept: the table is then still in the +/// document, still says where its text went, and a file pays one cell for a table it really +/// does contain -- which its own size already bounds. +fn budgeted_size(rows: usize, cols: usize, reserved: usize) -> (usize, usize, bool) { + let (mut nr, mut nc) = designcraft_doc::table::clamped_size(rows, cols); + let left = MAX_IMPORT_TABLE_CELLS.saturating_sub(reserved); + let over_budget = nr.saturating_mul(nc) > left; + if over_budget { + nc = nc.min(left.max(1)); + nr = nr.min((left / nc).max(1)); + } + (nr, nc, over_budget) +} + /// Preserve the legacy uniform field only when all four sides explicitly specify every /// property. Partial records must stay sparse so they can inherit from a parent style. pub(super) fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option { @@ -30,13 +54,14 @@ impl<'r> Importer<'r> { // aborts the process instead of failing (#1583). What is read is clamped, and the rest of // this function walks the clamped slices -- indexing `t.rows`/`t.columns` by the file's // own counts would be out of bounds. - let (nr, nc) = designcraft_doc::table::clamped_size(rows.len().max(1), cols.len().max(1)); + // + // Reserved before `Table::new`, so a cell's own story cannot spend the budget this table + // is counting on. + let (nr, nc, over_budget) = budgeted_size(rows.len().max(1), cols.len().max(1), self.table_cells); + self.table_cells = self.table_cells.saturating_add(nr.saturating_mul(nc)); if nr < rows.len() || nc < cols.len() { - self.warnings.push(format!( - "a table of {} rows and {} columns was read as {nr} x {nc}: more than a table can hold", - rows.len(), - cols.len() - )); + let why = if over_budget { "more than the tables in one file can hold between them" } else { "more than a table can hold" }; + self.warnings.push(format!("a table of {} rows and {} columns was read as {nr} x {nc}: {why}", rows.len(), cols.len())); } let rows = &rows[..nr.min(rows.len())]; let cols = &cols[..nc.min(cols.len())]; @@ -170,3 +195,57 @@ impl<'r> Importer<'r> { } } } + +#[cfg(test)] +mod budget_tests { + use super::{MAX_IMPORT_TABLE_CELLS, budgeted_size}; + + /// One table is capped, but a file chooses how many tables it has: three tables each at the + /// per-table cap asked for 750,000 cells, about 1 GB of `Cell`, with every table inside its + /// own limit (noted in review of #1583). + #[test] + fn the_tables_in_one_file_are_capped_between_them() { + let mut reserved = 0; + let mut sizes = Vec::new(); + for _ in 0..3 { + let (nr, nc, over) = budgeted_size(500, 1000, reserved); + reserved += nr * nc; + sizes.push((nr, nc, over)); + } + assert_eq!(sizes[0], (250, 1000, false), "the first table is held by the per-table cap alone"); + assert_eq!(sizes[1], (150, 1000, true), "the second gets what is left of the file's budget"); + assert_eq!(sizes[2], (1, 1, true), "and the third is kept, so its text still has somewhere to be"); + assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS + 1, "400,000 cells and the one the last table keeps"); + } + + /// The budget holds for any sequence of claimed sizes, and a table is never read as nothing: + /// a 0 x n table has no cells to hold the text the file puts in it. + #[test] + fn a_table_is_never_read_as_empty_and_the_budget_always_holds() { + let claims = [(0, 0), (1, 1), (7, 3), (usize::MAX, usize::MAX), (1, usize::MAX), (usize::MAX, 1), (10_000, 1_000), (2, 0)]; + let mut reserved = 0usize; + let mut tables = 0usize; + // Every claim, several times over, in an order that exhausts the budget part way through. + for _ in 0..4 { + for (rows, cols) in claims { + let (nr, nc, _) = budgeted_size(rows.max(1), cols.max(1), reserved); + assert!(nr >= 1 && nc >= 1, "{rows} x {cols} was read as {nr} x {nc}"); + reserved += nr * nc; + tables += 1; + assert!( + reserved <= MAX_IMPORT_TABLE_CELLS + tables, + "{tables} tables reserved {reserved} cells, past the budget by more than the one cell each keeps" + ); + } + } + assert!(reserved >= MAX_IMPORT_TABLE_CELLS, "this sequence does reach the budget: {reserved}"); + } + + /// Below the budget nothing is clamped by it, so an ordinary file is read exactly as before. + #[test] + fn an_ordinary_file_is_not_touched_by_the_budget() { + for (rows, cols) in [(1, 1), (2, 3), (50, 20), (250, 1000)] { + assert_eq!(budgeted_size(rows, cols, 0), (rows, cols, false), "{rows} x {cols}"); + } + } +} From 96b498ca708644acf5ada5999df497b3a17c0926 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 18:10:41 +0500 Subject: [PATCH 27/48] Import: a shared string and a footnote are not copied per reference (#1584) A footnote reference clones its note and walks it again, and an XLSX shared string is copied per cell that uses it, so one 1 MB footnote referenced 100k times meant around 100 GB. The text is kept at the first reference; later ones get the mark and an empty note, and the report says so. The spreadsheet reader stops at 64 MB of text and 400,000 collected cells. Carries both review fixes: - a footnote id is recorded only once its text has actually been kept, so a first reference arriving after the budget was spent no longer counts as "done" and tell the next reference the text is at a reference that has none; - the report and the note budget are both bounded. The duplicate warning names the id, so 400 ids referenced twice made 400 warnings, each pushed after a Vec::contains scan of all the earlier ones; warnings go through warn() now, which dedups through a set and stops at 100 distinct messages. And the budget counted only `text`, which holds a table's anchor and not its cells, so 18 notes of 600 KB of cell text retained 10,800,072 bytes against the 8 MB budget; note_bytes() walks the tables and their nested tables. Rebased onto the post-split main: the implementation stays in docx.rs and the tests moved to docx/tests.rs, so the test additions went there. --- crates/textimport/src/docx.rs | 87 +++++++++++++++++++- crates/textimport/src/docx/tests.rs | 118 ++++++++++++++++++++++++++++ crates/textimport/src/xlsx.rs | 72 ++++++++++++++++- 3 files changed, 270 insertions(+), 7 deletions(-) diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 94085ca69..5f1624267 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -236,7 +236,17 @@ struct Ctx { para_names: HashMap, char_names: HashMap, footnotes: HashMap, + /// Footnote ids already imported, and how much note text has been built. A reference clones + /// its note and walks it again, so one 1 MB footnote referenced 100k times meant ~100 GB + /// (#1584). Word gives each note its own id and references it once; a second reference to the + /// same id gets the mark without the text. + notes_done: std::collections::HashSet, + note_bytes: usize, warnings: Vec, + /// What has already been said, so saying it again is a lookup rather than a scan of + /// everything said so far. A warning that names a footnote id is one per id, and the + /// document chooses how many ids it has (noted in review). + warned: std::collections::HashSet, next_table: u64, numbering: Numbering, /// A paragraph style's list (`w:numPr` numId and ilvl, through `w:basedOn`), by style id. @@ -249,7 +259,47 @@ struct Ctx { default_para: String, } +/// A note's tables may hold tables of their own. The document model stops nesting at 64, so +/// counting no deeper than that reaches every cell a note can actually have. +const MAX_NOTE_DEPTH: usize = 64; + +/// The text `s` holds, including its tables' cell text. A table's cells each keep their text in +/// a story of their own rather than in `s.text`, which holds only the anchor, so counting +/// `s.text` scored a table of any size as a couple of bytes (noted in review). +fn note_bytes(s: &Story, depth: usize) -> usize { + let mut n = s.text.len(); + if depth == 0 { + return n; + } + for t in s.tables.values() { + for c in &t.cells { + n = n.saturating_add(note_bytes(&c.text, depth - 1)); + } + } + n +} + +/// Warnings one import reports. A document that earns more than this has earned the point. +const MAX_WARNINGS: usize = 100; + impl Ctx { + /// Report `message` once. Beyond [`MAX_WARNINGS`] distinct messages nothing further is kept, + /// not even in the set, so neither the report nor the memory behind it grows with the + /// document -- a footnote warning names an id, and a file can hold as many ids as it likes. + fn warn(&mut self, message: String) { + if self.warnings.len() > MAX_WARNINGS { + return; + } + if !self.warned.insert(message.clone()) { + return; + } + if self.warnings.len() == MAX_WARNINGS { + self.warnings.push(format!("The Word file has more than {MAX_WARNINGS} kinds of problem: the rest are not listed.")); + } else { + self.warnings.push(message); + } + } + fn para_style(&self, ppr: Option<&El>) -> String { ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.para_names.get(&id).cloned()).unwrap_or_else(|| self.default_para.clone()) } @@ -412,11 +462,39 @@ impl Ctx { "noBreakHyphen" => "\u{2011}".into(), "softHyphen" => "\u{AD}".into(), "footnoteReference" if allow_notes => { + const MAX_NOTE_BYTES: usize = 8 << 20; + let id = e.attr("id").unwrap_or_default().to_string(); + // The note's text is built once per id, and only while the budget lasts. A + // repeat reference, or one past the budget, still gets its mark and its + // number -- the reference is part of the text -- with an empty note (#1584). + // + // The id is recorded only once its text has actually been kept, further down: + // recording it here meant a first reference arriving after the budget was + // spent counted as "done" with an empty note, and the next reference to it + // then claimed the text was kept at the first one (noted in review). + let again = !id.is_empty() && self.notes_done.contains(&id); + let spent = self.note_bytes >= MAX_NOTE_BYTES; + if again || spent { + let len = st.len(); + if st.insert_note(len, "", ParaFormat::default()).is_some() { + let message = if again { + format!("Footnote {id} is referenced more than once in the Word file: the text is kept at its first reference.") + } else { + format!("The Word file's footnotes are longer than {} MB: the rest were imported empty.", MAX_NOTE_BYTES / (1 << 20)) + }; + self.warn(message); + } + continue; + } if let Some(note) = e.attr("id").and_then(|id| self.footnotes.get(id).cloned()) { let mut ns = Story::new(StoryId(0)); let counter = std::mem::take(&mut self.model_counter); self.blocks(¬e, &mut ns, false); self.model_counter = counter; + self.note_bytes = self.note_bytes.saturating_add(note_bytes(&ns, MAX_NOTE_DEPTH)); + if !id.is_empty() { + self.notes_done.insert(id.clone()); + } // Word puts the reference mark in the note text: we generate the number. let len = st.len(); match st.insert_note(len, "", ParaFormat::default()) { @@ -425,15 +503,13 @@ impl Ctx { n.text = ns; } } - None => self.warnings.push("A footnote in the Word file could not be imported.".into()), + None => self.warn("A footnote in the Word file could not be imported.".into()), } } continue; } "drawing" | "pict" | "object" => { - if !self.warnings.iter().any(|w| w.starts_with("Images")) { - self.warnings.push("Images and drawings in the Word file were not imported (place them separately).".into()); - } + self.warn("Images and drawings in the Word file were not imported (place them separately).".into()); continue; } _ => continue, @@ -485,7 +561,10 @@ pub fn import(bytes: &[u8]) -> Result { para_names: HashMap::new(), char_names: HashMap::new(), footnotes: HashMap::new(), + notes_done: std::collections::HashSet::new(), + note_bytes: 0, warnings: vec![], + warned: std::collections::HashSet::new(), next_table: 0, numbering: Numbering::default(), style_lists: HashMap::new(), diff --git a/crates/textimport/src/docx/tests.rs b/crates/textimport/src/docx/tests.rs index c70ca851f..7f1e425cf 100644 --- a/crates/textimport/src/docx/tests.rs +++ b/crates/textimport/src/docx/tests.rs @@ -89,6 +89,124 @@ fn a_measure_or_size_a_file_cannot_mean_is_bounded() { assert_eq!(size, None, "a size the engine cannot mean is dropped, so the style's size stands"); } +/// A footnote reference clones its note and walks it again, so one large footnote referenced +/// many times multiplied: the audit's case is a 1 MB note referenced 100k times, around +/// 100 GB, which aborts rather than fails (#1584). The text is kept at the first reference; +/// later ones get the mark and an empty note, and the report says so. +#[test] +fn a_footnote_referenced_many_times_is_imported_once() { + let note_text = "x".repeat(2_000); + let footnotes = format!(r#"{note_text}"#); + // One note, referenced 50 times. + let refs = r#""#.repeat(50); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // Every reference is still in the text: the marks are the document's content. + assert_eq!(i.story.notes.len(), 50, "all 50 references are kept"); + let with_text = i.story.notes.iter().filter(|n| !n.text.text.is_empty()).count(); + assert_eq!(with_text, 1, "only the first reference carries the note's text"); + let total: usize = i.story.notes.iter().map(|n| n.text.text.len()).sum(); + assert!(total <= note_text.len() + 64, "the note text is stored once, not 50 times: {total} bytes"); + assert!(i.warnings.iter().any(|w| w.contains("referenced more than once")), "the report says what it did: {:?}", i.warnings); + + // A document whose notes each have their own id is unaffected. + let many = (2..6).map(|id| format!(r#"note {id}"#)).collect::(); + let footnotes = format!(r#"{many}"#); + let refs = (2..6).map(|id| format!(r#""#)).collect::(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + assert_eq!(i.story.notes.len(), 4); + assert!(i.story.notes.iter().all(|n| n.text.text.contains("note")), "each distinct note keeps its text"); + assert!(!i.warnings.iter().any(|w| w.contains("referenced more than once")), "{:?}", i.warnings); +} + +/// A footnote's table keeps its cell text in stories of its own, so the budget counted a +/// table of any size as the couple of bytes of its anchor and the cap did not hold (noted in +/// review of #1584). Eighteen notes of ~600 KB of cell text each are 10.8 MB of table text +/// against an 8 MB budget. +#[test] +fn a_footnote_table_counts_toward_the_note_budget() { + let big = "x".repeat(600_000); + let cell = format!(r#"{big}"#); + let notes: String = (2..20).map(|id| format!(r#"{cell}"#)).collect(); + let footnotes = format!(r#"{notes}"#); + let refs: String = (2..20).map(|id| format!(r#""#)).collect(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // Every reference is still a mark in the text. + assert_eq!(i.story.notes.len(), 18, "all 18 references are kept"); + // What was retained is the budget plus at most the one note that was already being built + // when it ran out -- not all 10.8 MB. + let held: usize = i.story.notes.iter().map(|n| note_bytes(&n.text, MAX_NOTE_DEPTH)).sum(); + assert!(held <= (8 << 20) + big.len() + 1024, "the cell text is inside the budget: {held} bytes"); + assert!(held > big.len(), "and the notes that fit were imported in full: {held} bytes"); + assert!(i.warnings.iter().any(|w| w.contains("longer than 8 MB")), "the budget warning is reported: {:?}", i.warnings); +} + +/// The report is one line per distinct footnote id, and the file chooses how many ids it +/// has: 400 ids referenced twice each meant 400 warnings, every one of them pushed after a +/// `Vec::contains` scan of all the earlier ones (noted in review of #1584). Both the report +/// and the set behind it stop at the cap. +#[test] +fn the_report_does_not_grow_with_the_document() { + let ids = 2..402; + // Small notes, so the byte budget is not what stops this -- the ids are. + let notes: String = ids.clone().map(|id| format!(r#"note {id}"#)).collect(); + let footnotes = format!(r#"{notes}"#); + let refs: String = ids + .clone() + .flat_map(|id| { + let one = format!(r#""#); + [one.clone(), one] + }) + .collect(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // The document is imported in full: every reference is a mark in the text. + assert_eq!(i.story.notes.len(), 800, "all 800 references are kept"); + let duplicates = i.warnings.iter().filter(|w| w.contains("referenced more than once")).count(); + assert!(duplicates >= 1, "the repeats are reported: {:?}", i.warnings); + assert!(i.warnings.len() <= MAX_WARNINGS + 1, "400 duplicated ids do not make 400 warnings: {} warnings", i.warnings.len()); + // And the last line says the list was cut, rather than ending without explanation. + assert!(i.warnings.iter().any(|w| w.contains("the rest are not listed")), "{:?}", i.warnings); +} + +/// When the budget runs out before a note's *first* reference, that reference gets an empty +/// note -- and must not be recorded as the one that kept the text, or a later reference to the +/// same id would be told the text is at a reference that has none (noted in review of #1584). +#[test] +fn a_note_whose_first_reference_is_past_the_budget_is_not_recorded_as_kept() { + // Notes totalling more than the 8 MB budget, so the later ids are never built. + let big = "x".repeat(600_000); + let notes: String = (2..20).map(|id| format!(r#"{big}"#)).collect(); + let footnotes = format!(r#"{notes}"#); + // Each id referenced twice, in order. + let refs: String = (2..20) + .flat_map(|id| { + let one = format!(r#""#); + [one.clone(), one] + }) + .collect(); + let doc = format!(r#"{refs}"#); + let i = import(&docx(&doc, "", &footnotes)).unwrap(); + + // Every reference is kept, and the budget is respected. + assert_eq!(i.story.notes.len(), 36, "all 36 references are in the text"); + let total: usize = i.story.notes.iter().map(|n| n.text.text.len()).sum(); + assert!(total <= (8 << 20) + big.len(), "the note text stays inside the budget: {total} bytes"); + + // The "referenced more than once" warning is only about ids whose text was actually kept. + // A note that was never built cannot have kept its text at a first reference. + let kept: std::collections::HashSet<&str> = i.story.notes.iter().filter(|n| !n.text.text.is_empty()).map(|n| n.text.text.as_str()).collect(); + if i.warnings.iter().any(|w| w.contains("referenced more than once")) { + assert!(!kept.is_empty(), "that warning implies some text was kept: {:?}", i.warnings); + } + assert!(i.warnings.iter().any(|w| w.contains("longer than 8 MB")), "the budget warning is the one this document earns: {:?}", i.warnings); +} + #[test] fn imports_styles_runs_footnotes_and_tables() { let styles = format!( diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index a26665ecc..7f129cce1 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -212,19 +212,41 @@ pub fn import(bytes: &[u8]) -> Result { .transpose()? .map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect()) .unwrap_or_default(); + // A `t="s"` cell holds an index into the shared strings and each one is materialised as its + // own `String`, so one large shared string referenced by many cells multiplies: a 1 MB string + // and 3M references is terabytes, allocated before the grid cap below ever applies, and an + // allocation that large aborts the process rather than failing (#1584). Collection carries a + // byte budget and a cell count of its own. + const MAX_TEXT_BYTES: usize = 64 << 20; + const MAX_COLLECTED_CELLS: usize = 400_000; let mut cells: Vec<((usize, usize), String)> = Vec::new(); + let mut text_bytes = 0usize; + let mut cut = false; if let Some(data) = sheet.child("sheetData") { - for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() { + 'rows: for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() { for (ci, c) in row.els().filter(|e| e.name == "c").enumerate() { let at = c.attr("r").and_then(cell_ref).unwrap_or((ri, ci)); let v = value(c, &shared); - if !v.is_empty() { - cells.push((at, v)); + if v.is_empty() { + continue; } + if cells.len() >= MAX_COLLECTED_CELLS || text_bytes.saturating_add(v.len()) > MAX_TEXT_BYTES { + cut = true; + break 'rows; + } + text_bytes = text_bytes.saturating_add(v.len()); + cells.push((at, v)); } } } let mut warnings = Vec::new(); + if cut { + warnings.push(format!( + "the worksheet holds more than {} cells or {} MB of text: the rest was not read", + MAX_COLLECTED_CELLS, + MAX_TEXT_BYTES / (1 << 20) + )); + } if cells.is_empty() { warnings.push("the worksheet is empty".into()); } @@ -293,6 +315,50 @@ mod tests { buf.into_inner() } + /// A `t="s"` cell materialises its own copy of the shared string, so one big string with many + /// references multiplies: the audit's case is a 1 MB string and 3M cells, terabytes, built + /// before the grid cap applies (#1584). Collection now carries its own byte budget. + #[test] + fn a_shared_string_referenced_many_times_does_not_multiply() { + // A 64 KB shared string referenced 2,000 times would be 128 MB of cell text; the budget + // is 64 MB, so collection stops and says so. + let big = "y".repeat(64 * 1024); + let cells: String = (1..=2_000).map(|r| format!(r#"0"#)).collect(); + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let o = zip::write::SimpleFileOptions::default(); + for (name, body) in [ + ( + "xl/workbook.xml", + r#""#.to_string(), + ), + ( + "xl/_rels/workbook.xml.rels", + r#""#.to_string(), + ), + ("xl/sharedStrings.xml", format!("{big}")), + ("xl/worksheets/sheet1.xml", format!("{cells}")), + ] { + z.start_file(name, o).unwrap(); + z.write_all(body.as_bytes()).unwrap(); + } + z.finish().unwrap(); + } + let imp = import(&buf.into_inner()).unwrap(); + let table = imp.story.tables.values().next().expect("a table"); + let text: usize = (0..table.nrows()) + .flat_map(|r| (0..table.ncols()).map(move |c| (r, c))) + .filter_map(|(r, c)| table.cell(r, c).map(|cell| cell.text.text.len())) + .sum(); + assert!(text <= 64 << 20, "the table holds at most the budget: {text} bytes"); + assert!(imp.warnings.iter().any(|w| w.contains("was not read")), "the report says the sheet was cut: {:?}", imp.warnings); + + // An ordinary workbook is unaffected and reports nothing of the sort. + let plain = import(&workbook()).unwrap(); + assert!(!plain.warnings.iter().any(|w| w.contains("was not read")), "{:?}", plain.warnings); + } + #[test] fn first_sheet_used_range_becomes_a_table() { let imp = import(&workbook()).unwrap(); From 352609117fbb7deed1b37c16f7b9f3c8654bbe0d Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 18:20:13 +0500 Subject: [PATCH 28/48] A table size Create Table cannot mean is refused A size that was given but is not one a table can have was corrected in silence: {"rows": 0} made a table with one row, -1 and 1.5 made the default four, and 10000 was capped at 500 -- so a script asking for a shape the command cannot honour got a different table and no indication of it. A size that is absent still takes the default; a size that is present and outside the range is refused, naming the key and the range. Rebased onto the post-split main: `table.insert` moved to table/structure.rs and the tests to table/sizing.rs. The helper is named `given` rather than `count`, which that module already imports from table/target.rs for the repeat count of a row or column insert. --- crates/engine/src/cmd/table/sizing.rs | 46 ++++++++++++++++++++++++ crates/engine/src/cmd/table/structure.rs | 22 +++++++++--- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/table/sizing.rs b/crates/engine/src/cmd/table/sizing.rs index cd1d1af53..bcb661906 100644 --- a/crates/engine/src/cmd/table/sizing.rs +++ b/crates/engine/src/cmd/table/sizing.rs @@ -320,3 +320,49 @@ mod resize_border_tests { assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err()); } } + +#[cfg(test)] +mod insert_size_tests { + use serde_json::json; + + use crate::Session; + + /// A table size that is given but is not one a table can have used to be corrected in + /// silence: `{"rows": 0}` made a one-row table, `-1` and `1.5` made the default four, and + /// 10000 was capped at 500 — so a caller's mistake became a table nobody asked for. + #[test] + fn a_table_size_insert_cannot_mean_is_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": ""})).unwrap(); + let at = |s: &mut Session| { + s.execute("text.select", &json!({"story": frame["story"].clone(), "anchor": 0, "focus": 0})).unwrap(); + }; + for (params, names) in [ + (json!({"rows": 0}), "rows 0: 1 to 500"), + (json!({"rows": -1}), "rows -1: 1 to 500"), + (json!({"rows": 1.5}), "rows 1.5: 1 to 500"), + (json!({"rows": 10000}), "rows 10000: 1 to 500"), + (json!({"cols": 0}), "cols 0: 1 to 200"), + (json!({"columns": 201}), "cols 201: 1 to 200"), + (json!({"headerRows": 101}), "headerRows 101: 0 to 100"), + (json!({"footerRows": -2}), "footerRows -2: 0 to 100"), + ] { + at(&mut s); + let e = s.execute("table.insert", ¶ms).expect_err("refused").to_string(); + assert!(e.contains(names), "{params}: {e}"); + } + // Nothing was inserted by any of those. + at(&mut s); + assert!(s.execute("table.get", &json!({})).is_err(), "no table yet"); + // The sizes a table can have still work, and absent means the default four by four. + at(&mut s); + s.execute("table.insert", &json!({})).unwrap(); + let t = s.execute("table.get", &json!({})).unwrap(); + assert_eq!(t["cells"].as_array().unwrap().len(), 16, "four by four"); + at(&mut s); + s.execute("table.insert", &json!({"rows": 1, "cols": 2, "headerRows": 1})).unwrap(); + let t = s.execute("table.get", &json!({})).unwrap(); + assert_eq!(t["cells"].as_array().unwrap().len(), 4, "one body row and one header row, two columns"); + } +} diff --git a/crates/engine/src/cmd/table/structure.rs b/crates/engine/src/cmd/table/structure.rs index 65bcdb3d6..7ac075517 100644 --- a/crates/engine/src/cmd/table/structure.rs +++ b/crates/engine/src/cmd/table/structure.rs @@ -6,10 +6,24 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { if t.cell.is_some() { return Err(bad("table.insert", "tables can't be nested in cells")); } - let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize; - let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize; - let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; - let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; + // A size that is given but is not one a table can have used to be corrected in silence: + // `{"rows": 0}` made a table with one row, `-1` and `1.5` made the default four, and 10000 + // was capped at 500. Named `given` rather than `count`, which this module already imports + // for the repeat count of a row or column insert. + let given = |key: &str, alt: Option<&str>, default: u64, range: std::ops::RangeInclusive| -> Result { + let v = p.get(key).or_else(|| alt.and_then(|a| p.get(a))).filter(|v| !v.is_null()); + match v { + None => Ok(default as usize), + Some(v) => match v.as_u64().filter(|n| range.contains(n)) { + Some(n) => Ok(n as usize), + None => Err(bad("table.insert", format!("{key} {v}: {} to {}", range.start(), range.end()))), + }, + } + }; + let rows = given("rows", None, 4, 1..=500)?; + let cols = given("cols", Some("columns"), 4, 1..=200)?; + let header = given("headerRows", None, 0, 0..=100)?; + let footer = given("footerRows", None, 0, 0..=100)?; let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc(); From 87b49d997376a3bdb3ce42ac7967efb0519c2c68 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 28 Oct 2026 19:09:25 +0500 Subject: [PATCH 29/48] PDF export: SOURCE_DATE_EPOCH stands in for the clock, so exports repeat A PDF export recorded the second it was made in, so exporting one document twice gave different files although the pages matched -- and the instance id krilla derives from the bytes followed the timestamp, so nothing about the two files was comparable. SOURCE_DATE_EPOCH stands in for the clock when the export is given no date, as the reproducible-builds specification defines it. A date passed in still wins, and a value the PDF date format cannot hold -- malformed, negative, or past the range -- is ignored rather than refusing the export or writing a date no reader can parse. With the epoch pinned the two files are identical byte for byte. Rebased onto the post-split main: export_time and pinned_or live in export/metadata.rs beside now_unix, which they stand in front of, and the call site is in export/pipeline.rs. The ROADMAP keeps upstream's 2026-10-17 entry with this one above it. --- ROADMAP.md | 1 + crates/pdf/src/export/metadata.rs | 60 +++++++++++++++++++++++++++++++ crates/pdf/src/export/pipeline.rs | 4 +-- crates/pdf/src/lib.rs | 3 +- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..68e286450 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -431,6 +431,7 @@ Newest first. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. +- **2026-10-19** · A PDF export can repeat its bytes: it recorded the second it was made in, so exporting one document twice gave different files though the pages matched. `SOURCE_DATE_EPOCH` stands in for the clock when the export is given no date, as the reproducible-builds specification defines it; a date passed in still wins, and a malformed value is ignored rather than refusing the export. The timestamp was the only thing that differed, so the files are now identical byte for byte. No readiness percentage change. - **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded font program, including placed PDF forms; `validate-pdf` reports the font and page (#909). Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically. diff --git a/crates/pdf/src/export/metadata.rs b/crates/pdf/src/export/metadata.rs index 619a0f5a5..b5993453f 100644 --- a/crates/pdf/src/export/metadata.rs +++ b/crates/pdf/src/export/metadata.rs @@ -11,6 +11,27 @@ pub(super) fn now_unix() -> Option { None } +/// The date an exported file records, when the caller gives none: `SOURCE_DATE_EPOCH` if it holds +/// a non-negative whole number of seconds, else the clock. Pinning it makes an export repeat its +/// bytes, which is otherwise impossible -- the only thing that differs between two exports of one +/// document is the timestamp, and the instance id krilla derives from the bytes follows it. +/// +/// +pub(super) fn export_time() -> Option { + pinned_or(|name| std::env::var(name).ok(), now_unix) +} + +/// [`export_time`] with the environment and the clock given, so a test can show which wins. +fn pinned_or(get: impl Fn(&str) -> Option, clock: impl Fn() -> Option) -> Option { + let pinned = get("SOURCE_DATE_EPOCH").and_then(|v| v.trim().parse::().ok()).filter(|secs| PDF_DATES.contains(secs)); + pinned.or_else(clock) +} + +/// The dates a PDF can hold, as Unix seconds: `date_time` clamps the year to 0..=9999, so a value +/// outside this would be recorded as some other date rather than the one asked for. A value that +/// cannot be honoured is left to the clock, as a malformed one is. +const PDF_DATES: std::ops::RangeInclusive = 0..=253_402_300_799; + /// The document information from File Info (title, author, description, keywords). `title` and /// `author` from the export options replace File Info's. pub(super) fn file_info_metadata(doc: &Document, title: Option<&str>, author: Option<&str>) -> Metadata { @@ -49,3 +70,42 @@ pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) { let (y, t) = designcraft_doc::time::utc_parts(t, 0); (y, t.month() as u8, t.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8) } + +#[cfg(test)] +mod export_time_tests { + use super::pinned_or; + + /// What an export records when the caller gives no date: `SOURCE_DATE_EPOCH` when it holds a + /// non-negative whole number of seconds, the clock whenever it does not. The environment and + /// the clock are given here rather than set, so no test changes this process's environment -- + /// that is `unsafe` under edition 2024 and would reach every test beside it. + #[test] + fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() { + let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned); + let clock = || Some(1_000_000); + assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins"); + assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "the epoch itself counts"); + assert_eq!(pinned_or(env(Some(" 1791208980 ")), clock), Some(1_791_208_980), "spaces around it are allowed"); + // A date a PDF cannot hold is left to the clock too: `date_time` would clamp the year and + // record some other date. 253402300799 is 9999-12-31T23:59:59Z, the last one it can. + assert_eq!(pinned_or(env(Some("253402300799")), clock), Some(253_402_300_799), "the last date a PDF can hold"); + for ignored in [ + None, + Some(""), + Some(" "), + Some("-1"), + Some("later"), + Some("1791208980.5"), + Some("99999999999999999999"), + Some("253402300800"), + Some("9223372036854775807"), + ] { + assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock"); + } + // A platform with no clock and no variable records no date at all. + assert_eq!(pinned_or(env(None), || None), None); + assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock"); + // Only that variable is read. + assert_eq!(pinned_or(|name: &str| (name == "TZ").then(|| "1791208980".to_owned()), clock), Some(1_000_000)); + } +} diff --git a/crates/pdf/src/export/pipeline.rs b/crates/pdf/src/export/pipeline.rs index ca9d1a988..ffef45c87 100644 --- a/crates/pdf/src/export/pipeline.rs +++ b/crates/pdf/src/export/pipeline.rs @@ -1,6 +1,6 @@ use super::common::{tf, to_path}; use super::images::ImageContentCache; -use super::metadata::{date_time, file_info_metadata, now_unix}; +use super::metadata::{date_time, export_time, file_info_metadata}; use super::*; /// Export `doc` as PDF bytes. @@ -162,7 +162,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions }; let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..base }; let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let created = opts.created.or_else(now_unix); + let created = opts.created.or_else(export_time); let metadata = Metadata { created: created.map(date_time), ..file_info_metadata(doc, opts.title.as_deref(), opts.author.as_deref()) }; let mut writer = Writer::new(settings, metadata); let pdf = writer.document_mut(); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7d6682c6d..987e7ea97 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -136,7 +136,8 @@ pub struct PdfOptions { /// Title for the metadata; `None` = the document title. pub title: Option, pub author: Option, - /// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). + /// Creation date as Unix seconds (UTC). `None` takes `SOURCE_DATE_EPOCH` when it names a date + /// a PDF can hold, else the clock (native) / omitted (wasm). pub created: Option, /// Tagged PDF: stories become paragraphs in reading order, graphics figures with their alt /// text, parent-page items and printer's marks artifacts. From 79b871b94fe03052a5f00eb10085ee2f31523a70 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:18:35 +0000 Subject: [PATCH 30/48] Strip story marker characters from placed plain text files Found while testing crates/textimport/src/lib.rs:140 (plain) and crates/engine/src/cmd/place_text.rs:372 (place_text) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/links.rs | 10 ++++++++++ crates/textimport/src/lib.rs | 11 ++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 7a6d3a001..9c19a9a10 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1092,6 +1092,16 @@ mod text_link_tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn placed_plain_text_drops_story_markers() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let b64 = crate::cmd::base64_encode("a\u{E009}b\u{E000}c\u{E002}d".as_bytes()); + let r = s.execute("file.place", &json!({"base64": b64, "name": "n.txt", "rect": [72, 72, 300, 200]})).unwrap(); + let st = s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap(); + assert_eq!(st.text, "abcd"); + } + #[test] fn create_links_when_placing_text_files_preference() { let dir = std::env::temp_dir().join(format!("dc-text-link-pref-{}", std::process::id())); diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs index 56eac1a00..eb4189156 100644 --- a/crates/textimport/src/lib.rs +++ b/crates/textimport/src/lib.rs @@ -117,9 +117,12 @@ pub fn import(name: &str, bytes: &[u8]) -> Result { Ok(plain(&tagged::decode(bytes))) } -/// Plain text: line breaks become paragraphs (CRLF / CR normalised). +/// Plain text: line breaks become paragraphs (CRLF / CR normalised). The private-use characters +/// (U+E000–U+E1FF) that stories use as markers are dropped, since a stray one would be read as a +/// marker without its payload. pub fn plain(text: &str) -> Imported { let t = text.trim_start_matches('\u{FEFF}').replace("\r\n", "\n").replace('\r', "\n"); + let t: String = t.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); let story = Story::with_text(designcraft_doc::StoryId(0), &t, Default::default()); Imported { story, para_styles: vec![], char_styles: vec![], warnings: vec![] } } @@ -137,6 +140,12 @@ mod tests { assert!(matches!(import("x.doc", b"\xD0\xCF"), Err(ImportError::Unsupported(_)))); } + #[test] + fn plain_text_drops_story_markers() { + let i = import("a.txt", "a\u{E009}b\u{E000}c\u{E002}d".as_bytes()).unwrap(); + assert_eq!(i.story.text, "abcd"); + } + #[test] fn plain_text_utf16() { let mut le = vec![0xFF, 0xFE]; From 32119f34ee95d5c4e17360d56ee4bdfe5b15d0fb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 13:07:31 -0400 Subject: [PATCH 31/48] Grant network access so update checks can reach GitHub (#1954) --- .../linux/flatpak/ai.storyteller.designcraft.bundle.yml | 7 ++++--- packaging/linux/flatpak/ai.storyteller.designcraft.yml | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml index 2cfbe6a5e..755d2e2a3 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml @@ -22,6 +22,9 @@ finish-args: - --socket=fallback-x11 # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc + # Network: update checks reach GitHub. This shares the host network namespace, so the optional + # --control server (127.0.0.1 in the sandbox) is reachable from the host. + - --share=network # GPU access for egui's renderer (Vulkan, or GL as a fallback). - --device=dri # Open/Save, Place and Export go through the file-chooser portal (rfd's xdg-portal backend) @@ -30,9 +33,7 @@ finish-args: # line, Open Recent, linked images placed in a layout) work in the usual places. - --filesystem=xdg-documents - --filesystem=xdg-pictures - # Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in - # the sandbox; `flatpak override --user --share=network ai.storyteller.designcraft` exposes it - # to host agents) and home/host filesystem access. + # Home/host filesystem access is deliberately not granted. modules: - name: designcraft buildsystem: simple diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.yml index 5335d7a66..1dc2ee4ed 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.yml @@ -23,6 +23,9 @@ finish-args: - --socket=fallback-x11 # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. - --share=ipc + # Network: update checks reach GitHub. This shares the host network namespace, so the optional + # --control server (127.0.0.1 in the sandbox) is reachable from the host. + - --share=network # GPU access for egui's renderer (Vulkan, or GL as a fallback). - --device=dri # Open/Save, Place and Export go through the file-chooser portal (rfd's xdg-portal backend) @@ -31,9 +34,7 @@ finish-args: # line, Open Recent, linked images placed in a layout) work in the usual places. - --filesystem=xdg-documents - --filesystem=xdg-pictures - # Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in - # the sandbox; `flatpak override --user --share=network ai.storyteller.designcraft` exposes it - # to host agents) and home/host filesystem access. + # Home/host filesystem access is deliberately not granted. build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: From 3a1edcd76dc30a2219624ce1d30f27adc61df083 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 15:35:48 -0400 Subject: [PATCH 32/48] Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#1959) * Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds The shared release workflow cross-compiles FreeBSD aarch64 in an x86_64 VM and sets CARGO_BUILD_TARGET. package.sh ignored it: it built for the host, read binaries from target/release, took the arch from uname -m (amd64 in the VM), and ran the cross-built designcraft-cli --version on the host. Take the arch from the target, build and read binaries from the target's directory, and skip the host-side --version run when cross-compiling. Unset CARGO_BUILD_TARGET keeps the old behavior. * Pick FreeBSD archive arch from CARGO_BUILD_TARGET with a release_arch helper Follow soundcraft's pattern: native builds use uname -m, aarch64-unknown-freebsd and x86_64-unknown-freebsd map to their arch, and any other target exits 2. The --version check runs only when the archive arch matches the host arch. --- packaging/freebsd/package.sh | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index d11434be0..363ef1e22 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -29,13 +29,25 @@ while [[ $# -gt 0 ]]; do esac done -# FreeBSD's uname says amd64; release file names use x86_64 like the Linux and Windows ones. -MACHINE="$(uname -m)" -case "$MACHINE" in - amd64 | x86_64) ARCH=x86_64 ;; - arm64 | aarch64) ARCH=aarch64 ;; - *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; +# Release file names use x86_64 and aarch64 like the Linux and Windows ones. +release_arch() { + local arch="$1" + case "$arch" in + amd64 | x86_64) echo x86_64 ;; + arm64 | aarch64) echo aarch64 ;; + *) echo "unsupported architecture $arch" >&2; return 2 ;; + esac + return +} +# The release workflow cross-compiles aarch64 in an x86_64 VM with CARGO_BUILD_TARGET set to +# aarch64-unknown-freebsd: name the archive after the target, not the host's uname -m. +case "${CARGO_BUILD_TARGET:-}" in + "") ARCH="$(release_arch "$(uname -m)")" ;; + aarch64-unknown-freebsd | x86_64-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;; + *) echo "CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET is not a supported FreeBSD target" >&2; exit 2 ;; esac +# Only binaries for this machine can run here (the --version check at the end). +HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="" if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 @@ -47,14 +59,15 @@ echo "==> DesignCraft $VERSION for FreeBSD $ARCH" # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory. export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" if [[ "$SKIP_BUILD" = 0 ]]; then - (cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli) + (cd "$ROOT" && cargo build --release --locked ${CARGO_BUILD_TARGET:+--target "$CARGO_BUILD_TARGET"} \ + -p designcraft -p designcraft-cli) fi WORK="$CARGO_TARGET_DIR/freebsd-package" STAGE="$WORK/$BASENAME" rm -rf "$WORK" mkdir -p "$WORK" -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release" OUT_DIR="$DIST" if [[ "$DRY_RUN" = 1 ]]; then # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds. @@ -95,7 +108,11 @@ mkdir -p "$OUT_DIR" tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" echo "wrote $OUT_DIR/$BASENAME.tar.gz" -"$STAGE/bin/designcraft-cli" --version +if [[ "$ARCH" = "$HOST_ARCH" ]]; then + "$STAGE/bin/designcraft-cli" --version +else + echo "skipping --version: $ARCH binaries do not run on this $HOST_ARCH host" +fi if [[ "$DRY_RUN" = 1 ]]; then echo "==> dry run: tarball contents" tar -tzvf "$OUT_DIR/$BASENAME.tar.gz" From 855d6063250b31b30619eaeac15a07e7d37049f7 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:13:45 -0400 Subject: [PATCH 33/48] Show file and message dialogs without blocking the frame (fixes macOS crash) (#1961) * Show file and message dialogs without blocking the frame A blocking rfd dialog inside the egui frame runs AppKit's modal loop; a file dragged from Finder and held over the window then re-enters winit's handler and aborts the app. Dialogs now go through craft-dialogs: a command without a path asks with a continuation and returns, the host shows the dialog at the end of the frame, and the continuation runs with the answer on a later frame, in the document it was asked for. Commands given a path stay synchronous. The replace-file question is a message box from the same queue. Quit and Close wait for a Save As dialog's answer. clippy.toml disallows rfd::FileDialog and rfd::MessageDialog. * Raise the open file panel when File > Open is chosen again * Repin craft-dialogs to the craft-libs PR head * Queue dialogs asked by different code or documents; hold answers behind newer dialogs Two asks for the same file dialog were treated as one, so Place for one document and then Choose Image, or Place again for another document, ran the first ask's action. An ask is now skipped (and the open panel raised) only when the same code asked for the same dialog for the same document. An answer that would replace an egui dialog opened since the asking (an unsaved-changes question, Preferences) waits until it closes; Quit counts it as pending. * Repin craft-dialogs to the craft-libs PR head * Allow the file dialogs' existence checks in the file guard * Repin craft-dialogs to storytold/craft-libs main --- Cargo.lock | 15 + Cargo.toml | 3 + apps/designcraft-web/src/web.rs | 2 - apps/designcraft/Cargo.toml | 2 + apps/designcraft/src/main.rs | 205 +----- clippy.toml | 2 + crates/engine/src/tests/file_guard.rs | 4 + crates/ui-egui/Cargo.toml | 3 + crates/ui-egui/src/app.rs | 11 + crates/ui-egui/src/app/file_dialogs.rs | 605 ++++++++++++++++++ crates/ui-egui/src/app/files.rs | 128 ++-- crates/ui-egui/src/app/frame.rs | 4 + crates/ui-egui/src/app/quit.rs | 39 +- crates/ui-egui/src/dialogs.rs | 64 +- crates/ui-egui/src/dialogs/tests.rs | 43 +- crates/ui-egui/src/lib.rs | 17 +- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/menus/export.rs | 58 +- crates/ui-egui/src/menus/run.rs | 128 ++-- crates/ui-egui/src/menus/tests.rs | 8 +- crates/ui-egui/src/menus/ui_commands.rs | 16 +- crates/ui-egui/src/panels/datamerge.rs | 11 +- crates/ui-egui/src/panels/interactive.rs | 42 +- crates/ui-egui/src/panels/library.rs | 102 +-- crates/ui-egui/src/panels/properties/links.rs | 28 +- crates/ui-egui/src/services.rs | 15 +- crates/ui-egui/src/shortcuts_dialog.rs | 57 +- crates/ui-egui/src/tests.rs | 94 ++- 28 files changed, 1159 insertions(+), 548 deletions(-) create mode 100644 crates/ui-egui/src/app/file_dialogs.rs diff --git a/Cargo.lock b/Cargo.lock index 146e64530..1c361aef2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,6 +1017,19 @@ dependencies = [ "rayon", ] +[[package]] +name = "craft-dialogs" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=1a11a4897865f7410c40d25ca6e452ce399a1a2c#1a11a4897865f7410c40d25ca6e452ce399a1a2c" +dependencies = [ + "block2 0.6.2", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "raw-window-handle", + "rfd", +] + [[package]] name = "craft-docpath" version = "0.1.0" @@ -1595,6 +1608,7 @@ dependencies = [ name = "designcraft-ui-egui" version = "0.14.0" dependencies = [ + "craft-dialogs", "craft-keymap", "craft-prepress", "craft-ui", @@ -1612,6 +1626,7 @@ dependencies = [ "egui", "egui_kittest", "log", + "raw-window-handle", "serde", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 4b801ca8d..139d06f01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,6 +101,9 @@ egui_kittest = "0.37" # Monotonic clock on native and web (`Instant`); screenshot settlement must not use wall time. web-time = "1" rfd = "0.17" +# Native file and message dialogs that never block the event loop (craft-libs). +craft-dialogs = { git = "https://github.com/storytold/craft-libs", rev = "1a11a4897865f7410c40d25ca6e452ce399a1a2c" } +raw-window-handle = "0.6" arboard = { version = "3.6", default-features = false } [workspace.lints.rust] diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index 7e0c8d7d9..19bc3a122 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -410,8 +410,6 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { ctx.request_repaint(); }); })), - // Exports ask for a name; the browser decides where the download goes. - pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), write: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))), download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))), inbox: Some(inbox), diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 36415546e..d706374c7 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -24,6 +24,8 @@ log = { workspace = true } winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Not called here: craft-dialogs shows the dialogs. rfd's default features keep its Wayland +# support on, so portal dialogs get the window as their parent. rfd = { workspace = true } arboard = { workspace = true } diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index dc46e65bc..5f056aab3 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -57,7 +57,7 @@ impl eframe::App for App { fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { self.app.raw_input_hook(ctx, raw); } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.startup.enter(); // `App::ui` is the only signal that a real layout pass ran. A discarded multipass first // pass reports index 0 once, so these once-per-frame counters/menu polls still run once. @@ -96,6 +96,9 @@ impl eframe::App for App { } #[cfg(target_os = "macos")] craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable); + // After the frame, never inside it: the dialog answers on a later frame. On macOS Save and + // message boxes are sheets on this window. + self.app.show_file_dialogs(Some(&*frame)); } fn on_exit(&mut self) { // A normal exit before the third frame is not a crash. @@ -216,157 +219,8 @@ fn save_prefs(app: &DesignApp) { } } -/// One file-type row in an open dialog. `open_filters` is what `pick_open` applies. -struct OpenFilter { - name: &'static str, - extensions: &'static [&'static str], -} - -fn open_filters(purpose: &str) -> &'static [OpenFilter] { - match purpose { - "swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }], - "script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }], - "shortcuts" => &[OpenFilter { name: "Shortcut set (JSON, InDesign text)", extensions: &["json", "txt"] }], - "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], - "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], - "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], - "library" => &[OpenFilter { name: "Object Library", extensions: &["dclib"] }], - "relink" => &[OpenFilter { - name: "Graphics", - extensions: &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"], - }], - "dataMerge" => &[OpenFilter { name: "Data source (CSV, TSV, text, Excel)", extensions: &["csv", "tsv", "tab", "txt", "xlsx"] }], - "place" => &[ - OpenFilter { - name: "Graphics and text", - extensions: &[ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "tif", - "tiff", - "bmp", - "psd", - "svg", - "pdf", - "ai", - "eps", - "txt", - "docx", - "rtf", - "md", - "xlsx", - "idml", - "designcraft", - "mp4", - "m4v", - "mov", - "webm", - "mp3", - "m4a", - "wav", - "ogg", - ], - }, - OpenFilter { - name: "Graphics", - extensions: &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"], - }, - OpenFilter { name: "Text (Word, RTF, plain, Excel)", extensions: &["docx", "rtf", "txt", "md", "xlsx"] }, - OpenFilter { name: "Video and sound", extensions: &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"] }, - ], - _ => &[ - OpenFilter { name: "DesignCraft, IDML or InDesign", extensions: &["designcraft", "idml", "indd", "indt"] }, - OpenFilter { name: "DesignCraft", extensions: &["designcraft"] }, - OpenFilter { name: "InDesign Markup (IDML)", extensions: &["idml"] }, - OpenFilter { name: "InDesign document or template (INDD, INDT)", extensions: &["indd", "indt"] }, - // Files saved without an extension; `file.open` tells the formats apart by content. - OpenFilter { name: "All files", extensions: &[ALL_FILES] }, - ], - } -} - -/// The "All files" pattern. The macOS panel merges every filter into one list of allowed types, -/// which takes type identifiers as well as extensions: `public.data` admits any file. -const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "*" }; - -/// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one. -/// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it. -fn suggested_extension(name: &str) -> Option<&str> { - std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace)) -} - -/// The file a save dialog's answer writes: `picked` with the suggested extension added when it has -/// none (Linux dialogs don't add it). A document always ends in `.designcraft`, as `file.save` -/// writes it. -fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path::PathBuf { - let Some(ext) = ext else { return picked }; - if ext.eq_ignore_ascii_case("designcraft") { - let p = picked.to_string_lossy(); - return designcraft_engine::cmd::with_document_extension(&p).into(); - } - if picked.extension().is_some() || picked.file_name().is_none() { - return picked; - } - let mut s = picked.into_os_string(); - s.push("."); - s.push(ext); - s.into() -} - -/// The folder a save dialog starts in and the name it suggests. A suggestion with a folder in -/// front (the document's, or the last one used) starts there while that folder still exists; -/// otherwise the system picks, as it did before. -fn save_dialog_start(suggested: &str) -> (Option, String) { - let path = std::path::Path::new(suggested); - let name = path.file_name().map_or_else(|| suggested.to_string(), |n| n.to_string_lossy().to_string()); - let folder = path.parent().filter(|f| !f.as_os_str().is_empty() && f.is_dir()).map(std::path::Path::to_path_buf); - (folder, name) -} - -/// The save dialog: the suggested name's extension as its filter, and the extension added to a -/// name typed without it. Adding it names another file than the one the dialog checked, so -/// replacing an existing one asks again. -fn pick_save(suggested: &str) -> Option { - let (folder, name) = save_dialog_start(suggested); - let name = name.as_str(); - let ext = suggested_extension(name); - let mut dialog = rfd::FileDialog::new().set_file_name(name); - if let Some(folder) = folder { - dialog = dialog.set_directory(folder); - } - if let Some(ext) = ext { - dialog = dialog.add_filter(ext.to_ascii_uppercase(), &[ext]); - } - let picked = dialog.save_file()?; - let path = picked_save_path(picked.clone(), ext); - if path != picked && path.exists() { - let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let answer = rfd::MessageDialog::new() - .set_level(rfd::MessageLevel::Warning) - .set_title("Replace File?") - .set_description(format!("“{file}” already exists. Do you want to replace it?")) - .set_buttons(rfd::MessageButtons::YesNo) - .show(); - if answer != rfd::MessageDialogResult::Yes { - return None; - } - } - Some(path.to_string_lossy().to_string()) -} - fn services() -> Services { Services { - pick_open: Some(Box::new(|purpose: &str| { - let mut dialog = rfd::FileDialog::new(); - for filter in open_filters(purpose) { - dialog = dialog.add_filter(filter.name, filter.extensions); - } - dialog.pick_file().map(|p| p.to_string_lossy().to_string()) - })), - pick_save: Some(Box::new(pick_save)), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())), @@ -636,16 +490,6 @@ mod tests { assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept"); } - #[test] - fn relink_open_dialog_lists_graphics() { - let filters = super::open_filters("relink"); - let exts: Vec<&str> = filters.iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); - for ext in ["png", "jpg", "tif", "psd", "svg", "pdf", "ai", "eps"] { - assert!(exts.contains(&ext), "{ext} is missing from the relink dialog: {exts:?}"); - } - assert!(!exts.contains(&"designcraft") && !exts.contains(&"idml")); - } - #[test] fn control_port_values_that_are_not_ports_are_ignored() { assert_eq!(super::control_port_from("--control", Some("7979".into())), Some(7979)); @@ -655,47 +499,6 @@ mod tests { assert_eq!(super::control_port_from("--control", Some(bad.into())), None, "{bad:?}"); } } - - #[test] - fn data_merge_open_dialog_lists_table_extensions() { - let filters = super::open_filters("dataMerge"); - let exts: Vec<&str> = filters.iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); - for ext in ["csv", "tsv", "tab", "txt", "xlsx"] { - assert!(exts.contains(&ext), "{ext} is missing from the dataMerge dialog: {exts:?}"); - } - assert!(!exts.iter().any(|ext| *ext == "designcraft" || *ext == "idml"), "dataMerge must not fall through to the document filters: {exts:?}"); - let place = super::open_filters("place"); - assert!(place.len() > 1, "the place dialog keeps a filter for each kind of file"); - assert!(place.iter().any(|filter| filter.extensions.contains(&"png"))); - let documents = super::open_filters(""); - for ext in ["designcraft", "idml", "indd", "indt"] { - assert!(documents.first().is_some_and(|filter| filter.extensions.contains(&ext)), "{ext} is missing from the first Open filter"); - } - assert!(documents.last().is_some_and(|filter| filter.extensions == [super::ALL_FILES]), "files without an extension can be picked"); - } - - #[test] - fn save_dialog_answers_get_the_suggested_extension() { - use std::path::PathBuf; - let save = |picked: &str, name: &str| super::picked_save_path(PathBuf::from(picked), super::suggested_extension(name)); - assert_eq!(save("/d/Brochure", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); - assert_eq!(save("/d/Brochure.idml", "Untitled.designcraft"), PathBuf::from("/d/Brochure.idml.designcraft")); - assert_eq!(save("/d/Brochure.designcraft", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); - assert_eq!(save("/d/Brochure", "Untitled.pdf"), PathBuf::from("/d/Brochure.pdf")); - assert_eq!(save("/d/Brochure.txt", "Untitled.rtf"), PathBuf::from("/d/Brochure.txt"), "another export format is kept"); - assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension"); - } - - /// #1653: the save dialog opens in the folder the suggestion names, unless it is gone. - #[test] - fn save_dialog_starts_in_the_suggested_folder_while_it_exists() { - let folder = std::env::temp_dir(); - let suggested = folder.join("Brochure.pdf"); - assert_eq!(super::save_dialog_start(&suggested.to_string_lossy()), (Some(folder.clone()), "Brochure.pdf".to_string())); - let gone = folder.join(format!("designcraft-gone-{}", std::process::id())).join("Brochure.pdf"); - assert_eq!(super::save_dialog_start(&gone.to_string_lossy()), (None, "Brochure.pdf".to_string()), "a folder deleted since: the system's own"); - assert_eq!(super::save_dialog_start("Untitled 1.2 Folder"), (None, "Untitled 1.2 Folder".to_string()), "a bare name: the system's own"); - } } #[cfg(all(test, unix))] diff --git a/clippy.toml b/clippy.toml index 3522a15b6..7b45b7aca 100644 --- a/clippy.toml +++ b/clippy.toml @@ -4,5 +4,7 @@ allow-panic-in-tests = true allow-indexing-slicing-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, + { path = "rfd::FileDialog", reason = "use DesignApp::ask_open/ask_save (craft-dialogs): a blocking dialog inside the frame aborts the app on macOS" }, + { path = "rfd::MessageDialog", reason = "use craft-dialogs' ask_message: a blocking dialog inside the frame aborts the app on macOS" }, ] cognitive-complexity-threshold = 30 diff --git a/crates/engine/src/tests/file_guard.rs b/crates/engine/src/tests/file_guard.rs index d27a13b79..f62e5c338 100644 --- a/crates/engine/src/tests/file_guard.rs +++ b/crates/engine/src/tests/file_guard.rs @@ -34,6 +34,10 @@ const ALLOWED: &[(&str, &str)] = &[ "fonts/src/fontdb/system.rs", "the installed-font scan reads the platform's fixed font folders (`system_font_dirs`: no command parameter or document field names one), and `load_document_fonts` asks the caller's `allow` check about its folder and each font file before opening either", ), + ( + "ui-egui/src/app/file_dialogs.rs", + "the host's own file dialogs: it only tests whether a path the user picked in the system dialog exists (the replace-file question, the folder a Save dialog opens in), then re-runs the command with that path, which the engine checks as usual", + ), ( "mcp/src/tools.rs", "`screenshot` reads back the PNG the connected app just wrote. Connected mode cannot be combined with the roots (the CLI refuses the pair), and the app's own engine checks the path it writes", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 2ada1d0e9..ffe855f4a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -49,3 +49,6 @@ toml = "1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] craft-update = { workspace = true } +# File and message dialogs that never block the frame; the browser build uses its own path. +craft-dialogs = { workspace = true } +raw-window-handle = { workspace = true } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 85c9264ec..383362594 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -13,6 +13,7 @@ use super::{ mod clipboard; mod commands; mod control; +pub(crate) mod file_dialogs; mod files; mod frame; mod ime; @@ -34,8 +35,18 @@ impl DesignApp { // The app owns the monotonic origin for recovery timing and frame pacing; `now_ms` itself // is process-global so screenshot deadlines stay comparable across hosts. let now = now_ms() / 1000.0; + #[cfg(not(target_arch = "wasm32"))] + let dialog_wake = std::sync::Arc::new(std::sync::OnceLock::new()); DesignApp { pending_pdf: None, + #[cfg(not(target_arch = "wasm32"))] + file_dialogs: file_dialogs::new_dialogs(dialog_wake.clone()), + #[cfg(not(target_arch = "wasm32"))] + asked: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + held_answers: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + dialog_wake, session, ui: UiState::default(), services, diff --git a/crates/ui-egui/src/app/file_dialogs.rs b/crates/ui-egui/src/app/file_dialogs.rs new file mode 100644 index 000000000..0d3a2b4da --- /dev/null +++ b/crates/ui-egui/src/app/file_dialogs.rs @@ -0,0 +1,605 @@ +//! File and message dialogs that never block the frame (craft-dialogs). +//! +//! A blocking dialog runs the platform's modal loop inside the frame. On macOS an event that +//! arrives meanwhile (a file dragged from Finder and held over the window) re-enters winit's +//! handler and aborts the app. So a command that needs a path asks for it with +//! [`DesignApp::ask_open`] or [`DesignApp::ask_save`] and returns; the host shows the dialog at the +//! end of the frame ([`DesignApp::show_file_dialogs`]), and the continuation runs with the chosen +//! path on a later frame. A command given a path (control channel, scripts, tests) never asks. +//! +//! The browser build has no native dialogs: Open goes through `Services::open_async` and the +//! inbox, and Save hands the suggested name to `Services::download`. + +use super::{DesignApp, Value, json}; + +/// What runs with the chosen path, on the frame the answer arrives. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) type Then = Box Result>; + +/// Whether an answer still needs the document that was active when the dialog was asked for. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Bound { + /// The continuation edits or reads the active document: it runs in that document, and not + /// at all once it has closed. + Document, + /// The continuation doesn't depend on a document (opening one, a library, a book). + App, +} + +/// Which dialog was asked for, by whom and for which document. Asking again with all three the +/// same (File › Open chosen twice) brings the open panel to the front instead of queueing a +/// second one; anything else queues. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, PartialEq)] +pub(crate) struct AskKey { + request: craft_dialogs::Request, + /// The document active when the dialog was asked for, when the answer depends on it. + doc: Option, + /// The code that asked (`#[track_caller]` through the asking helpers): Place, Place with + /// Options and Choose Image share one dialog but not what they do with its answer. + origin: &'static std::panic::Location<'static>, +} + +/// A dialog's continuation: what it was asked for and what its answer does. +#[cfg(not(target_arch = "wasm32"))] +pub struct Asked { + key: AskKey, + /// The egui dialog open when it was asked for (Color Settings, Find): its answer may update or + /// replace that one, but waits while any other is open. + dialog: Option, + step: Step, +} + +/// An answer waiting for the egui dialog opened since it was asked for to close, so it doesn't +/// replace it (an unsaved-changes question, Preferences with unsaved edits). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) struct Held { + doc: Option, + dialog: Option, + then: Then, + path: String, +} + +#[cfg(not(target_arch = "wasm32"))] +enum Step { + /// Open: run `then` with the file chosen. + Open(Then), + /// Save: `ext` is added to a name typed without it, then `then` runs, after the replace-file + /// question when the added extension names a file that exists. + Save { ext: Option, then: Then }, + /// The replace-file question about `path`: Yes runs `then` with it. + Replace { path: String, then: Then }, +} + +#[cfg(not(target_arch = "wasm32"))] +pub type Dialogs = craft_dialogs::Dialogs; + +/// A dialog that can't show (no window, no platform dialog) and the reason. +#[cfg(not(target_arch = "wasm32"))] +const DIALOG_UNAVAILABLE: &str = "The file dialog couldn't be shown"; + +/// The browser build has no dialog for this purpose. +#[cfg(target_arch = "wasm32")] +const NO_PICKER: &str = "needs a file picker"; + +/// The dialogs, with a wake-up that repaints once the first frame has given it the context. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn new_dialogs(ctx: std::sync::Arc>) -> Dialogs { + craft_dialogs::Dialogs::new(std::sync::Arc::new(move || { + if let Some(ctx) = ctx.get() { + ctx.request_repaint(); + } + })) +} + +impl DesignApp { + /// Ask for a file to open for `purpose` (`open`, `place`, `relink`…: it sets the file types). + /// `then` runs with its path on a later frame; Cancel runs nothing. + #[track_caller] + pub(crate) fn ask_open( + &mut self, + purpose: &str, + bound: Bound, + then: impl FnOnce(&mut DesignApp, String) -> Result + 'static, + ) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + let mut dialog = craft_dialogs::FileDialog::open(); + for filter in open_filters(purpose) { + dialog = dialog.filter(filter.name, filter.extensions); + } + self.ask_file(dialog, bound, Step::Open(Box::new(then))); + Ok(Value::Null) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (purpose, bound, then); + Err(NO_PICKER.into()) + } + } + + /// Ask where to save `name`: beside the active document once it has been saved, else in the + /// folder of the last file saved or exported (#1653). `then` runs with the path on a later + /// frame; Cancel runs nothing. In a browser the name is the download's and `then` runs now. + #[track_caller] + pub(crate) fn ask_save( + &mut self, + name: &str, + bound: Bound, + then: impl FnOnce(&mut DesignApp, String) -> Result + 'static, + ) -> Result { + if self.services.download.is_some() { + return then(self, name.to_string()); + } + #[cfg(not(target_arch = "wasm32"))] + { + let suggested = self.suggested_save_path(name); + let (folder, name) = save_dialog_start(&suggested); + let ext = suggested_extension(&name).map(str::to_string); + let mut dialog = craft_dialogs::FileDialog::save().file_name(name.as_str()); + if let Some(folder) = folder { + dialog = dialog.directory(folder); + } + if let Some(ext) = &ext { + dialog = dialog.filter(ext.to_ascii_uppercase(), &[ext.as_str()]); + } + self.ask_file(dialog, bound, Step::Save { ext, then: Box::new(then) }); + Ok(Value::Null) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (bound, then); + Err(NO_PICKER.into()) + } + } + + /// `name` after the folder a save dialog starts in. Only the last part of a name goes after + /// the folder: a title may hold a slash, and an empty name would make the folder itself the + /// suggested file. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn suggested_save_path(&self, name: &str) -> String { + use std::path::{Path, PathBuf}; + let folder = self + .session + .active() + .and_then(|d| d.path.as_deref()) + .and_then(folder_of) + .or_else(|| self.ui.last_save_folder.as_ref().map(PathBuf::from)); + match (folder, Path::new(name).file_name()) { + (Some(f), Some(file)) => f.join(file).to_string_lossy().to_string(), + _ => name.to_string(), + } + } + + /// Queues `dialog` unless the same code already asked for the same one for the same document + /// and it is still showing or waiting: choosing File › Open again while its panel is up brings + /// that panel to the front instead of showing a second one. + #[cfg(not(target_arch = "wasm32"))] + #[track_caller] + fn ask_file(&mut self, dialog: craft_dialogs::FileDialog, bound: Bound, step: Step) { + let key = + AskKey { request: craft_dialogs::Request::File(dialog.clone()), doc: self.bound_doc(bound), origin: std::panic::Location::caller() }; + if self.asked.contains(&key) { + self.file_dialogs.raise(); + return; + } + self.asked.push(key.clone()); + let dialog_open = self.ui.dialog.as_ref().map(|d| d.id.clone()); + self.file_dialogs.ask(dialog, Asked { key, dialog: dialog_open, step }); + } + + /// A dialog is showing or waiting to show, or its answer hasn't run yet. + pub(crate) fn file_dialogs_pending(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + return !self.asked.is_empty() || !self.held_answers.is_empty(); + #[cfg(target_arch = "wasm32")] + false + } + + #[cfg(not(target_arch = "wasm32"))] + fn bound_doc(&self, bound: Bound) -> Option { + match bound { + Bound::Document => self.session.active().map(|d| d.uid), + Bound::App => None, + } + } + + /// Shows the next dialog asked for. The host calls it at the end of every frame with its + /// window: on macOS Save and message boxes are sheets on it. + #[cfg(not(target_arch = "wasm32"))] + pub fn show_file_dialogs(&mut self, parent: Option<&W>) { + self.file_dialogs.show_pending(parent); + } + + /// Runs the continuations of the dialogs that have answered, and of answers held until the + /// egui dialog in their way has closed. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn handle_file_dialog_answers(&mut self) { + use craft_dialogs::{Answer, MessageResult}; + for held in std::mem::take(&mut self.held_answers) { + self.continue_in(held); + } + for (asked, answer) in self.file_dialogs.take_answers() { + let Asked { key, dialog, step } = asked; + if let Some(i) = self.asked.iter().position(|k| *k == key) { + self.asked.remove(i); + } + let doc = key.doc; + match (step, answer) { + (_, Answer::Unavailable(why)) => self.status_error(DIALOG_UNAVAILABLE, why), + (Step::Open(then), Answer::Paths(paths)) => { + if let Some(path) = paths.into_iter().next() { + self.continue_in(Held { doc, dialog, then, path: path.to_string_lossy().to_string() }); + } + } + (Step::Save { ext, then }, Answer::Paths(paths)) => { + let Some(picked) = paths.into_iter().next() else { continue }; + let path = picked_save_path(picked.clone(), ext.as_deref()); + if path != picked && path.exists() { + self.ask_replace(key, dialog, path, then); + } else { + self.saved_to(Held { doc, dialog, then, path: path.to_string_lossy().to_string() }); + } + } + (Step::Replace { path, then }, Answer::Message(MessageResult::Yes)) => self.saved_to(Held { doc, dialog, then, path }), + // Cancel, or No to replacing the file: nothing happens. + _ => {} + } + } + } + + /// The extension added to the name typed names another file than the one the dialog checked, + /// so replacing an existing one asks again. + #[cfg(not(target_arch = "wasm32"))] + fn ask_replace(&mut self, key: AskKey, dialog: Option, path: std::path::PathBuf, then: Then) { + let lang = &self.ui.language; + let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let message = craft_dialogs::Message { + level: craft_dialogs::MessageLevel::Warning, + title: crate::i18n::tr(lang, "Replace File?").to_string(), + description: crate::i18n::tr(lang, "“{file}” already exists. Do you want to replace it?").replace("{file}", &file), + buttons: craft_dialogs::MessageButtons::YesNo, + }; + let key = AskKey { request: craft_dialogs::Request::Message(message.clone()), ..key }; + self.asked.push(key.clone()); + let path = path.to_string_lossy().to_string(); + self.file_dialogs.ask_message(message, Asked { key, dialog, step: Step::Replace { path, then } }); + } + + /// A save path chosen: its folder is where the next save dialog starts. + #[cfg(not(target_arch = "wasm32"))] + fn saved_to(&mut self, answer: Held) { + if let Some(folder) = folder_of(&answer.path) { + self.ui.last_save_folder = Some(folder.to_string_lossy().to_string()); + } + self.continue_in(answer); + } + + /// Runs the answer's continuation in the document the dialog was asked for, made active again + /// if the user moved to another meanwhile; nothing runs once it has closed. While an egui + /// dialog opened since the asking is up, the answer waits for it to close. + #[cfg(not(target_arch = "wasm32"))] + fn continue_in(&mut self, answer: Held) { + if self.ui.dialog.as_ref().is_some_and(|d| Some(&d.id) != answer.dialog.as_ref()) { + self.held_answers.push(answer); + return; + } + let Held { doc, then, path, .. } = answer; + if let Some(uid) = doc { + let Some(index) = self.session.documents().iter().position(|d| d.uid == uid) else { + self.status(crate::i18n::tr(&self.ui.language, "Cancelled: the document the dialog was for has closed")); + return; + }; + if self.session.active().map(|d| d.uid) != Some(uid) + && let Err(e) = self.run("file.activate", json!({"index": index})) + { + self.status(e); + return; + } + } + // As for a UI command: a dialog that replaces Find releases its scope. + let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); + if let Err(e) = then(self, path) { + self.status(e); + } + if was_find && self.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { + self.session.reset_find_scope(); + } + } +} + +/// `p` with `key` set to the chosen path: a command re-run with the answer to its dialog. +pub(crate) fn with_path(p: &Value, key: &str, path: String) -> Value { + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + q[key] = json!(path); + q +} + +#[cfg(not(target_arch = "wasm32"))] +fn folder_of(path: &str) -> Option { + std::path::Path::new(path).parent().filter(|f| !f.as_os_str().is_empty()).map(std::path::Path::to_path_buf) +} + +/// One file-type row in an open dialog. +#[cfg(not(target_arch = "wasm32"))] +struct OpenFilter { + name: &'static str, + extensions: &'static [&'static str], +} + +#[cfg(not(target_arch = "wasm32"))] +const GRAPHICS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"]; + +#[cfg(not(target_arch = "wasm32"))] +fn open_filters(purpose: &str) -> &'static [OpenFilter] { + match purpose { + "swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }], + "script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }], + "shortcuts" => &[OpenFilter { name: "Shortcut set (JSON, InDesign text)", extensions: &["json", "txt"] }], + "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], + "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], + "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], + "dtd" => &[OpenFilter { name: "DTD", extensions: &["dtd"] }], + "library" => &[OpenFilter { name: "Object Library", extensions: &["dclib"] }], + "relink" => &[OpenFilter { name: "Graphics", extensions: GRAPHICS }], + "dataMerge" => &[OpenFilter { name: "Data source (CSV, TSV, text, Excel)", extensions: &["csv", "tsv", "tab", "txt", "xlsx"] }], + "place" => &[ + OpenFilter { + name: "Graphics and text", + extensions: &[ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "tif", + "tiff", + "bmp", + "psd", + "svg", + "pdf", + "ai", + "eps", + "txt", + "docx", + "rtf", + "md", + "xlsx", + "idml", + "designcraft", + "mp4", + "m4v", + "mov", + "webm", + "mp3", + "m4a", + "wav", + "ogg", + ], + }, + OpenFilter { name: "Graphics", extensions: GRAPHICS }, + OpenFilter { name: "Text (Word, RTF, plain, Excel)", extensions: &["docx", "rtf", "txt", "md", "xlsx"] }, + OpenFilter { name: "Video and sound", extensions: &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"] }, + ], + _ => &[ + OpenFilter { name: "DesignCraft, IDML or InDesign", extensions: &["designcraft", "idml", "indd", "indt"] }, + OpenFilter { name: "DesignCraft", extensions: &["designcraft"] }, + OpenFilter { name: "InDesign Markup (IDML)", extensions: &["idml"] }, + OpenFilter { name: "InDesign document or template (INDD, INDT)", extensions: &["indd", "indt"] }, + // Files saved without an extension; `file.open` tells the formats apart by content. + OpenFilter { name: "All files", extensions: &[ALL_FILES] }, + ], + } +} + +/// The "All files" pattern. The macOS panel merges every filter into one list of allowed types, +/// which takes type identifiers as well as extensions: `public.data` admits any file. +#[cfg(not(target_arch = "wasm32"))] +const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "*" }; + +/// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one. +/// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it. +#[cfg(not(target_arch = "wasm32"))] +fn suggested_extension(name: &str) -> Option<&str> { + std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace)) +} + +/// The file a save dialog's answer writes: `picked` with the suggested extension added when it has +/// none (Linux dialogs don't add it). A document always ends in `.designcraft`, as `file.save` +/// writes it. +#[cfg(not(target_arch = "wasm32"))] +fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path::PathBuf { + let Some(ext) = ext else { return picked }; + if ext.eq_ignore_ascii_case("designcraft") { + let p = picked.to_string_lossy(); + return designcraft_engine::cmd::with_document_extension(&p).into(); + } + if picked.extension().is_some() || picked.file_name().is_none() { + return picked; + } + let mut s = picked.into_os_string(); + s.push("."); + s.push(ext); + s.into() +} + +/// The folder a save dialog starts in and the name it suggests. A suggestion with a folder in +/// front (the document's, or the last one used) starts there while that folder still exists; +/// otherwise the system picks. +#[cfg(not(target_arch = "wasm32"))] +fn save_dialog_start(suggested: &str) -> (Option, String) { + let path = std::path::Path::new(suggested); + let name = path.file_name().map_or_else(|| suggested.to_string(), |n| n.to_string_lossy().to_string()); + let folder = path.parent().filter(|f| !f.as_os_str().is_empty() && f.is_dir()).map(std::path::Path::to_path_buf); + (folder, name) +} + +#[cfg(test)] +pub(crate) mod testing { + use craft_dialogs::{Answer, MessageResult, Request}; + + use super::DesignApp; + + /// No window: dialogs answered by an override need none. + pub(crate) struct NoWindow; + + impl raw_window_handle::HasWindowHandle for NoWindow { + fn window_handle(&self) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::Unavailable) + } + } + + impl raw_window_handle::HasDisplayHandle for NoWindow { + fn display_handle(&self) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::Unavailable) + } + } + + /// Every file dialog answers `path` (`None`: Cancel); every message box answers Yes. + pub(crate) fn answer_with(path: Option) -> impl FnMut(&Request) -> Answer + 'static { + move |request| match (request, &path) { + (Request::Message(_), _) => Answer::Message(MessageResult::Yes), + (Request::File(_), Some(path)) => Answer::Paths(vec![path.into()]), + (Request::File(_), None) => Answer::Cancelled, + } + } + + impl DesignApp { + /// From now on, `answer` answers every dialog instead of a native one. + pub(crate) fn answer_dialogs(&mut self, answer: impl FnMut(&Request) -> Answer + 'static) { + self.file_dialogs.set_override(Some(Box::new(answer))); + } + + /// Shows the dialogs asked for (the override answers them) and runs their answers, until + /// none is left: what the frames after the asking one do. + pub(crate) fn settle_dialogs(&mut self) { + for _ in 0..8 { + self.show_file_dialogs(None::<&NoWindow>); + self.handle_file_dialog_answers(); + if !self.file_dialogs.is_open() && self.file_dialogs.queued() == 0 { + return; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::testing::answer_with; + use super::{ALL_FILES, open_filters, picked_save_path, save_dialog_start, suggested_extension}; + + #[test] + fn relink_open_dialog_lists_graphics() { + let exts: Vec<&str> = open_filters("relink").iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); + for ext in ["png", "jpg", "tif", "psd", "svg", "pdf", "ai", "eps"] { + assert!(exts.contains(&ext), "{ext} is missing from the relink dialog: {exts:?}"); + } + assert!(!exts.contains(&"designcraft") && !exts.contains(&"idml")); + } + + #[test] + fn data_merge_open_dialog_lists_table_extensions() { + let exts: Vec<&str> = open_filters("dataMerge").iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); + for ext in ["csv", "tsv", "tab", "txt", "xlsx"] { + assert!(exts.contains(&ext), "{ext} is missing from the dataMerge dialog: {exts:?}"); + } + assert!(!exts.iter().any(|ext| *ext == "designcraft" || *ext == "idml"), "dataMerge must not fall through to the document filters: {exts:?}"); + let place = open_filters("place"); + assert!(place.len() > 1, "the place dialog keeps a filter for each kind of file"); + assert!(place.iter().any(|filter| filter.extensions.contains(&"png"))); + let documents = open_filters(""); + for ext in ["designcraft", "idml", "indd", "indt"] { + assert!(documents.first().is_some_and(|filter| filter.extensions.contains(&ext)), "{ext} is missing from the first Open filter"); + } + assert!(documents.last().is_some_and(|filter| filter.extensions == [ALL_FILES]), "files without an extension can be picked"); + } + + #[test] + fn save_dialog_answers_get_the_suggested_extension() { + let save = |picked: &str, name: &str| picked_save_path(PathBuf::from(picked), suggested_extension(name)); + assert_eq!(save("/d/Brochure", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); + assert_eq!(save("/d/Brochure.idml", "Untitled.designcraft"), PathBuf::from("/d/Brochure.idml.designcraft")); + assert_eq!(save("/d/Brochure.designcraft", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); + assert_eq!(save("/d/Brochure", "Untitled.pdf"), PathBuf::from("/d/Brochure.pdf")); + assert_eq!(save("/d/Brochure.txt", "Untitled.rtf"), PathBuf::from("/d/Brochure.txt"), "another export format is kept"); + assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension"); + } + + /// #1653: the save dialog opens in the folder the suggestion names, unless it is gone. + #[test] + fn save_dialog_starts_in_the_suggested_folder_while_it_exists() { + let folder = std::env::temp_dir(); + let suggested = folder.join("Brochure.pdf"); + assert_eq!(save_dialog_start(&suggested.to_string_lossy()), (Some(folder.clone()), "Brochure.pdf".to_string())); + let gone = folder.join(format!("designcraft-gone-{}", std::process::id())).join("Brochure.pdf"); + assert_eq!(save_dialog_start(&gone.to_string_lossy()), (None, "Brochure.pdf".to_string()), "a folder deleted since: the system's own"); + assert_eq!(save_dialog_start("Untitled 1.2 Folder"), (None, "Untitled 1.2 Folder".to_string()), "a bare name: the system's own"); + } + + /// Place, Graphic Cell and Choose Image share the Place dialog, but each keeps its own + /// request, as does Place asked for another document; Place asked again for the same one + /// doesn't queue a second dialog. + #[test] + fn different_askers_and_documents_queue_their_own_dialogs() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.answer_dialogs(answer_with(None)); + app.run("file.new", serde_json::json!({})).unwrap(); + app.run("app.placeDialog", serde_json::json!({})).unwrap(); + app.run("app.graphicCell", serde_json::json!({})).unwrap(); + assert_eq!(app.file_dialogs.queued(), 2); + app.run("file.new", serde_json::json!({})).unwrap(); + app.run("app.placeDialog", serde_json::json!({})).unwrap(); + assert_eq!(app.file_dialogs.queued(), 3); + app.run("app.placeDialog", serde_json::json!({})).unwrap(); + assert_eq!(app.file_dialogs.queued(), 3); + } + + /// An answer that arrives while an egui dialog opened since is up (Preferences, an + /// unsaved-changes question) waits for it to close instead of replacing it. + #[test] + fn an_answer_waits_for_a_dialog_opened_after_the_asking() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Held.designcraft"); + let mut source = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + source.run("file.new", serde_json::json!({})).unwrap(); + source.run("file.saveAs", serde_json::json!({"path": path.to_string_lossy()})).unwrap(); + + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.answer_dialogs(answer_with(Some(path.to_string_lossy().to_string()))); + app.run("app.openDialog", serde_json::json!({})).unwrap(); + app.ui.dialog = Some(crate::dialogs::Dialog::new("preferences", serde_json::json!({}))); + app.settle_dialogs(); + assert!(app.session.documents().is_empty(), "the answer waits"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences")); + assert!(app.file_dialogs_pending(), "Quit still sees the answer coming"); + app.ui.dialog = None; + app.handle_file_dialog_answers(); + assert_eq!(app.session.documents().len(), 1); + assert!(!app.file_dialogs_pending()); + } + + /// File › Open without a path asks and returns at once; the document opens on a later frame, + /// when the dialog's answer arrives. The frame never waits for the dialog. + #[test] + fn file_open_without_a_path_opens_the_chosen_document_when_the_dialog_answers() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Chosen.designcraft"); + let mut source = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + source.run("file.new", serde_json::json!({})).unwrap(); + source.run("file.saveAs", serde_json::json!({"path": path.to_string_lossy()})).unwrap(); + + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.answer_dialogs(answer_with(Some(path.to_string_lossy().to_string()))); + app.run("app.openDialog", serde_json::json!({})).unwrap(); + assert!(app.session.documents().is_empty(), "nothing opens before the dialog has answered"); + // Asking again while the dialog is up doesn't queue a second one. + app.run("app.openDialog", serde_json::json!({})).unwrap(); + assert_eq!(app.file_dialogs.queued(), 1); + app.settle_dialogs(); + assert_eq!(app.session.documents().len(), 1, "the chosen document opened once"); + assert_eq!(app.session.active().and_then(|d| d.path.clone()).map(PathBuf::from), Some(path)); + } +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 916a47317..49d2bd757 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,4 @@ -use super::{DesignApp, ImportRequest, ImportedFile, Value, canvas, dialogs, json, shortcut_import}; +use super::{DesignApp, ImportRequest, ImportedFile, Value, canvas, dialogs, file_dialogs, json, shortcut_import}; #[cfg(test)] use super::{Services, Session}; #[cfg(test)] @@ -7,82 +7,59 @@ use crate::Inbox; use crate::test_window; impl DesignApp { - /// The host's save dialog for `name`, started beside the active document once it has been - /// saved, else in the folder of the last file saved or exported (#1653). The answer's folder - /// is remembered for the next one. - pub(crate) fn pick_save(&mut self, name: &str) -> Option { - use std::path::{Path, PathBuf}; - let folder_of = |path: &str| Path::new(path).parent().filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf); - let folder = self - .session - .active() - .and_then(|d| d.path.as_deref()) - .and_then(folder_of) - .or_else(|| self.ui.last_save_folder.as_ref().map(PathBuf::from)); - // Only the last part of a name goes after the folder: a title may hold a slash, and an - // empty name would make the folder itself the suggested file. - let file = Path::new(name).file_name(); - let suggested = match (folder, file) { - (Some(f), Some(file)) => f.join(file).to_string_lossy().to_string(), - _ => name.to_string(), - }; - let picked = self.services.pick_save.as_mut().and_then(|f| f(&suggested))?; - if let Some(folder) = folder_of(&picked) { - self.ui.last_save_folder = Some(folder.to_string_lossy().to_string()); + /// Ask the host for a file to open (`open`) or place (`place`). A native dialog answers on a + /// later frame ([`Self::open_picked`]); in a browser the file arrives through the inbox. + #[track_caller] + pub fn pick_and_open(&mut self, purpose: &str) -> Result { + if let Some(open) = self.services.open_async.as_mut() { + let request = ImportRequest { purpose: purpose.into(), target: self.session.active().map(|d| d.uid) }; + open(request); + return Ok(Value::Null); } - Some(picked) + let bound = if purpose == "place" { file_dialogs::Bound::Document } else { file_dialogs::Bound::App }; + let purpose_owned = purpose.to_string(); + self.ask_open(purpose, bound, move |app, path| app.open_picked(&purpose_owned, path)) } - /// Ask the host for a file to open (`open`) or place (`place`). Synchronous pickers run the - /// command right away; asynchronous ones (web) deliver the file through the inbox. - pub fn pick_and_open(&mut self, purpose: &str) -> Result { - let target = self.session.active().map(|d| d.uid); - if let Some(pick) = self.services.pick_open.as_mut() { - return match pick(purpose) { - // Image Import Options: a multi-page PDF asks which page. - Some(path) if purpose == "place" && path.to_ascii_lowercase().ends_with(".pdf") => { - let pages = - self.services.read.as_mut().and_then(|r| r(&path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1); - if pages > 1 { - self.ui.dialog = - Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target}))); - Ok(Value::Null) - } else { - self.place_file(json!({"path": path})) - } - } - Some(path) if purpose == "place" => self.place_file(json!({"path": path})), - Some(path) => self.open_file("file.open", json!({"path": path})), - None => Ok(Value::Null), - }; + /// Open (`open`) or place (`place`) the file at `path`, chosen in a dialog or passed by a script. + pub(crate) fn open_picked(&mut self, purpose: &str, path: String) -> Result { + // Image Import Options: a multi-page PDF asks which page. + if purpose == "place" && path.to_ascii_lowercase().ends_with(".pdf") { + let target = self.session.active().map(|d| d.uid); + let pages = self.services.read.as_mut().and_then(|r| r(&path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1); + if pages > 1 { + self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target}))); + return Ok(Value::Null); + } + return self.place_file(json!({"path": path})); } - let request = self.import_request(purpose); - if let Some(open) = self.services.open_async.as_mut() { - open(request); + if purpose == "place" { + return self.place_file(json!({"path": path})); } - Ok(Value::Null) + self.open_file("file.open", json!({"path": path})) } /// Load Styles: ask the host for a document whose styles to load; `kind` is `paragraph`, - /// `character` or empty for both. A synchronous picker opens the dialog that chooses among - /// the file's styles; an asynchronous one (web) loads them all when the file arrives. + /// `character` or empty for both. A native dialog's answer opens the dialog that chooses + /// among the file's styles; in a browser they all load when the file arrives. + #[track_caller] pub fn pick_styles(&mut self, kind: &str) -> Result { designcraft_engine::cmd::has_doc(&self.session)?; - if let Some(pick) = self.services.pick_open.as_mut() { - let Some(path) = pick("styles") else { return Ok(Value::Null) }; - let found = self.run("style.load.list", json!({"path": path}))?; + if let Some(open) = self.services.open_async.as_mut() { + let purpose = if kind.is_empty() { "styles".to_string() } else { format!("styles:{kind}") }; + open(ImportRequest { purpose, target: self.session.active().map(|d| d.uid) }); + return Ok(Value::Null); + } + let kind = kind.to_string(); + self.ask_open("styles", file_dialogs::Bound::Document, move |app, path| { + let found = app.run("style.load.list", json!({"path": path}))?; let of = |k: &str| if kind.is_empty() || kind == k { found[k].clone() } else { json!([]) }; - self.ui.dialog = Some(dialogs::Dialog::new( + app.ui.dialog = Some(dialogs::Dialog::new( "loadStyles", json!({"path": path, "paragraph": of("paragraph"), "character": of("character"), "conflicts": "redefine"}), )); - return Ok(Value::Null); - } - let request = self.import_request(&if kind.is_empty() { "styles".to_string() } else { format!("styles:{kind}") }); - if let Some(open) = self.services.open_async.as_mut() { - open(request); - } - Ok(Value::Null) + Ok(Value::Null) + }) } pub fn import_request(&self, purpose: &str) -> ImportRequest { @@ -380,8 +357,7 @@ mod import_warning_tests { std::fs::write(&path, idml(true)).unwrap(); let picked = path.to_string_lossy().to_string(); let mut app = app(); - app.services.pick_open = Some(Box::new(move |_| Some(picked.clone()))); - let result = app.run("app.openDialog", json!({})); + let result = app.run("app.openDialog", json!({"path": picked})); std::fs::remove_file(&path).unwrap(); std::fs::remove_dir(path.parent().unwrap()).unwrap(); assert_eq!(result.unwrap()["warnings"].as_array().unwrap().len(), 2); @@ -687,9 +663,10 @@ pub(crate) mod browser_import_tests { let path = std::env::temp_dir().join(format!("designcraft-native-import-{}-{}.pdf", std::process::id(), app.session.doc().unwrap().uid)); std::fs::write(&path, pdf()).unwrap(); let pick_path = path.to_string_lossy().to_string(); - app.services.pick_open = Some(Box::new(move |_| Some(pick_path.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(pick_path))); app.services.read = Some(Box::new(|path| std::fs::read(path).map_err(|e| e.to_string()))); app.pick_and_open("place").unwrap(); + app.settle_dialogs(); assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport"); app.run("file.new", json!({"title": "B"})).unwrap(); app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2")); @@ -744,8 +721,8 @@ mod load_styles_tests { let path = std::env::temp_dir().join(format!("designcraft-load-styles-{}-{test}.designcraft", std::process::id())); std::fs::write(&path, client_bytes()).unwrap(); let picked = path.to_string_lossy().to_string(); - let services = Services { pick_open: Some(Box::new(move |purpose| (purpose == "styles").then(|| picked.clone()))), ..Default::default() }; - let mut app = DesignApp::new(Session::new(), services); + let mut app = DesignApp::new(Session::new(), Services::default()); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked))); app.run("file.new", json!({})).unwrap(); app.run("style.paragraph.create", json!({"name": "Body", "chars": {"size": 8}})).unwrap(); (app, path) @@ -760,6 +737,7 @@ mod load_styles_tests { fn load_all_text_styles_asks_which_styles_of_the_picked_file_to_load() { let (mut app, path) = app_and_file("all"); app.run("app.loadTextStyles", json!({})).unwrap(); + app.settle_dialogs(); let d = app.ui.dialog.as_mut().expect("the Load Styles dialog"); let row = |name: &str, conflict: bool| json!({"name": name, "conflict": conflict}); assert_eq!(d.id, "loadStyles"); @@ -778,6 +756,7 @@ mod load_styles_tests { fn the_dialog_applies_the_chosen_conflict_rule() { let (mut app, path) = app_and_file("rename"); app.run("app.loadParagraphStyles", json!({})).unwrap(); + app.settle_dialogs(); let d = app.ui.dialog.as_mut().unwrap(); assert_eq!(d.fields["character"], json!([]), "Load Paragraph Styles offers no character styles"); assert_eq!(d.fields["conflicts"], "redefine"); @@ -792,6 +771,7 @@ mod load_styles_tests { fn load_character_styles_offers_only_character_styles() { let (mut app, path) = app_and_file("character"); app.run("app.loadCharacterStyles", json!({})).unwrap(); + app.settle_dialogs(); assert_eq!(app.ui.dialog.as_ref().unwrap().fields["paragraph"], json!([])); dialogs::confirm(&mut app).unwrap(); std::fs::remove_file(&path).unwrap(); @@ -820,18 +800,22 @@ mod load_styles_tests { fn no_file_or_no_document_opens_no_dialog() { let (mut app, path) = app_and_file("none"); std::fs::remove_file(&path).unwrap(); - // The picked file can't be read. - assert!(app.run("app.loadTextStyles", json!({})).is_err()); + // The picked file can't be read: the status line says so. + app.run("app.loadTextStyles", json!({})).unwrap(); + app.settle_dialogs(); assert!(app.ui.dialog.as_ref().is_none_or(|d| d.id != "loadStyles")); + assert!(!app.ui.status.is_empty()); // The picker is cancelled. app.ui.dialog = None; - app.services.pick_open = Some(Box::new(|_| None)); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(None)); app.run("app.loadTextStyles", json!({})).unwrap(); + app.settle_dialogs(); assert!(app.ui.dialog.is_none()); // No document: the picker isn't shown at all. app.run("file.close", json!({})).unwrap(); - app.services.pick_open = Some(Box::new(|_| panic!("asked for a file without a document"))); + app.answer_dialogs(|_| panic!("asked for a file without a document")); assert!(app.run("app.loadTextStyles", json!({})).is_err()); + app.settle_dialogs(); } #[test] diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 66274d709..ceeb28743 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -27,7 +27,11 @@ impl DesignApp { self.update_screenshot_epoch(ctx); self.drain_control(ctx); updates_ui::tick(self, ctx); + #[cfg(not(target_arch = "wasm32"))] + let _ = self.dialog_wake.set(ctx.clone()); if self.fonts_ready && !self.host_order_pending() { + #[cfg(not(target_arch = "wasm32"))] + self.handle_file_dialog_answers(); self.drain_inbox(); self.open_os_documents(); } diff --git a/crates/ui-egui/src/app/quit.rs b/crates/ui-egui/src/app/quit.rs index 7e3686ac6..c1c67a493 100644 --- a/crates/ui-egui/src/app/quit.rs +++ b/crates/ui-egui/src/app/quit.rs @@ -49,10 +49,18 @@ impl DesignApp { ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); if self.quitting.is_none() { - self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None }); + self.quitting = + Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None, saving: None }); } } let Some(q) = self.quitting.as_ref() else { return }; + if q.saving.is_some() { + // Waiting for the save dialog; cancelled, it answers without saving and Quit stops. + if !self.file_dialogs_pending() { + self.cancel_quit(); + } + return; + } if let Some(uid) = q.pending { let asking = self.ui.dialog.as_ref().is_some_and(|d| { d.id == "closeDocument" @@ -116,16 +124,16 @@ impl DesignApp { return Ok(Value::Null); }; if !discard { - let saved = self.run("file.activate", json!({"index": i})).and_then(|_| self.run("app.save", json!({}))); + if let Some(q) = self.quitting.as_mut() { + q.pending = None; + q.saving = Some(uid); + } + let saved = self.run("file.activate", json!({"index": i})).and_then(|_| menus::save_then(self, move |app| app.quit_document_saved(uid))); if let Err(e) = saved { self.cancel_quit(); return Err(e); } - if self.session.documents().iter().find(|d| d.uid == uid).is_none_or(|d| d.is_dirty()) { - // No file chosen (or an asynchronous host save has not completed): stop quitting. - self.cancel_quit(); - return Ok(Value::Null); - } + return Ok(Value::Null); } if let Some(q) = self.quitting.as_mut() { if let Some(d) = self.session.documents().iter().find(|d| d.uid == uid) { @@ -136,6 +144,23 @@ impl DesignApp { Ok(Value::Null) } + /// The Save chosen during Quit has written `uid` (at once, or once its save dialog answered): + /// approve its current revision. Still unsaved, Quit stops. + fn quit_document_saved(&mut self, uid: u64) -> Result { + if self.quitting.as_ref().and_then(|q| q.saving) != Some(uid) { + return Ok(Value::Null); + } + let revision = self.session.documents().iter().find(|d| d.uid == uid).filter(|d| !d.is_dirty()).map(|d| d.revision); + match (revision, self.quitting.as_mut()) { + (Some(revision), Some(q)) => { + q.accepted.insert(uid, revision); + q.saving = None; + } + _ => self.cancel_quit(), + } + Ok(Value::Null) + } + /// Show the next unapproved document, or request a final close that revalidates all approvals. fn ask_next_unsaved(&mut self, ctx: &egui::Context) { let Some((i, uid)) = self.next_unapproved_document() else { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c46a2d343..bb3077103 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::DesignApp; +use crate::app::file_dialogs::{Bound, with_path}; use crate::theme::semibold; mod options; @@ -2644,18 +2645,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); check(ui, &mut d, "bpc", crate::i18n::tr(&app.ui.language, "Use Black Point Compensation")); if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Profile…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("icc")) + && let Err(e) = app.ask_open("icc", Bound::App, load_color_profile) { - match app.session.execute("color.loadProfile", &json!({"path": path})) { - Ok(_) => { - // Replayed in load order at start-up, so the latest load of a path comes last. - app.ui.color_profiles.retain(|p| p != &path); - app.ui.color_profiles.push(path); - let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); - d.fields.insert("profiles".into(), f["profiles"].clone()); - } - Err(e) => app.status_error("Color Settings", e), - } + app.status_error("Color Settings", e); } } "userDictionary" => user_dictionary(app, ui, &mut d), @@ -3628,19 +3620,29 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { } let stem = if title.is_empty() { doc_name } else { title }; let name = if stem.is_empty() { "Export.pdf".to_string() } else { format!("{stem}.pdf") }; - match app.pick_save(&name) { - Some(path) => { - params["path"] = json!(path); - crate::menus::export_pdf(app, ¶ms) - } - // The user cancelled the save dialog: export nothing. - None => Ok(Value::Null), - } + app.ask_save(&name, Bound::Document, move |app, path| crate::menus::export_pdf(app, &with_path(¶ms, "path", path))) } _ => confirm_part4(app, d), } } +/// Color Settings › Load Profile…: load the chosen profile and list it in the dialog if it is +/// still open. +fn load_color_profile(app: &mut DesignApp, path: String) -> Result { + if let Err(e) = app.session.execute("color.loadProfile", &json!({"path": path})) { + app.status_error("Color Settings", e); + return Ok(Value::Null); + } + // Replayed in load order at start-up, so the latest load of a path comes last. + app.ui.color_profiles.retain(|p| p != &path); + app.ui.color_profiles.push(path); + let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); + if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.id == "colorSettings") { + d.fields.insert("profiles".into(), f["profiles"].clone()); + } + Ok(Value::Null) +} + /// One slice of the dialogs' Apply; later slices take the ids this one doesn't. fn confirm_part4(app: &mut DesignApp, d: Dialog) -> Result { match d.id.as_str() { @@ -3785,20 +3787,20 @@ fn confirm_part5(app: &mut DesignApp, d: Dialog) -> Result { if d.b("quitting") { return app.confirm_quit_document(uid.ok_or("Missing document in quit confirmation")?, d.b("discard")); } - let index = |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid); + let index = move |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid); let Some(i) = index(app) else { return Ok(Value::Null) }; - if !d.b("discard") { - app.run("file.activate", json!({"index": i}))?; - app.run("app.save", json!({}))?; - // Still unsaved: the save was cancelled (no file chosen). Keep the document open. - if app.session.documents().get(i).is_none_or(|doc| doc.is_dirty()) { - return Ok(Value::Null); - } - } - match index(app) { - Some(i) => app.run("file.close", json!({"index": i})), - None => Ok(Value::Null), + let discard = d.b("discard"); + // Closes the document once it is saved (or at once, for Don't Save). Still unsaved, the + // save failed: the document stays open. + let close = move |app: &mut DesignApp| match index(app) { + Some(i) if discard || app.session.documents().get(i).is_some_and(|doc| !doc.is_dirty()) => app.run("file.close", json!({"index": i})), + _ => Ok(Value::Null), + }; + if discard { + return close(app); } + app.run("file.activate", json!({"index": i}))?; + crate::menus::save_then(app, close) } "loadStyles" => { let checked = |key: &str| -> Vec<&str> { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 173520e99..84cbf3bea 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -201,12 +201,16 @@ fn file_info_dialog_stores_the_fields_and_titles_the_pdf() { })); let names: Arc>> = Arc::new(Mutex::new(Vec::new())); let asked = names.clone(); - app.services.pick_save = Some(Box::new(move |name: &str| { - asked.lock().unwrap().push(name.to_string()); - Some("/tmp/designcraft-file-info.pdf".to_string()) - })); + app.answer_dialogs(move |request| match request { + craft_dialogs::Request::File(dialog) => { + asked.lock().unwrap().push(dialog.file_name.clone().unwrap_or_default()); + craft_dialogs::Answer::Paths(vec!["/tmp/designcraft-file-info.pdf".into()]) + } + craft_dialogs::Request::Message(_) => craft_dialogs::Answer::Message(craft_dialogs::MessageResult::Yes), + }); open_pdf_export(&mut app); confirm(&mut app).unwrap(); + app.settle_dialogs(); assert_eq!(names.lock().unwrap().as_slice(), ["flyer-v3.pdf"]); let pdf = String::from_utf8_lossy(&writes.lock().unwrap()[0]).into_owned(); assert!(pdf.contains("/Title(Spring Catalog)") && !pdf.contains("/Title(flyer-v3)"), "the PDF's title is File Info's"); @@ -214,6 +218,7 @@ fn file_info_dialog_stores_the_fields_and_titles_the_pdf() { open_pdf_export(&mut app); setup_fields(&mut app, json!({"title": "flyer-v3"})); confirm(&mut app).unwrap(); + app.settle_dialogs(); let asked = names.lock().unwrap().last().cloned().unwrap_or_default(); assert_eq!(std::path::Path::new(&asked).file_name().and_then(|n| n.to_str()), Some("flyer-v3.pdf"), "{asked}"); let pdf = String::from_utf8_lossy(&writes.lock().unwrap()[1]).into_owned(); @@ -1967,11 +1972,11 @@ fn find_scope_dialog_native_pdf_replacement_releases_bounds() { let seen_read = read.clone(); let pick_path = path.to_string_lossy().to_string(); let read_path = pick_path.clone(); - app.services.pick_open = Some(Box::new(move |purpose| { - assert_eq!(purpose, "place"); + app.answer_dialogs(move |request| { + assert_eq!(place_filter(request), Some("Graphics and text")); seen_pick.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Some(pick_path.clone()) - })); + craft_dialogs::Answer::Paths(vec![pick_path.clone().into()]) + }); app.services.read = Some(Box::new(move |path| { assert_eq!(path, read_path); seen_read.fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -1985,6 +1990,7 @@ fn find_scope_dialog_native_pdf_replacement_releases_bounds() { let active = (app.session.active_index(), app.session.documents().len()); // Exercise engine.execute -> UiRequest::Pick -> native host PDF replacement. scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + app.settle_dialogs(); assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 1); let dialog = app.ui.dialog.as_ref().unwrap(); @@ -2036,17 +2042,23 @@ fn find_scope_dialog_native_pdf_replacement_releases_bounds() { } } +/// The first file type an open dialog offers: Place's is "Graphics and text". +fn place_filter(request: &craft_dialogs::Request) -> Option<&str> { + let craft_dialogs::Request::File(dialog) = request else { return None }; + dialog.filters.first().map(|filter| filter.name.as_str()) +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn find_scope_dialog_native_picker_cancel_retains_bounds() { let (mut app, ctx, screen, sid) = scope_dialog(); let picked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let seen = picked.clone(); - app.services.pick_open = Some(Box::new(move |purpose| { - assert_eq!(purpose, "place"); + app.answer_dialogs(move |request| { + assert_eq!(place_filter(request), Some("Graphics and text")); seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - None - })); + craft_dialogs::Answer::Cancelled + }); let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let seen_read = read.clone(); app.services.read = Some(Box::new(move |_| { @@ -2062,6 +2074,7 @@ fn find_scope_dialog_native_picker_cancel_retains_bounds() { let active = (app.session.active_index(), app.session.documents().len()); let fields = app.ui.dialog.as_ref().unwrap().fields.clone(); scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + app.settle_dialogs(); assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 0); assert_eq!(app.ui.dialog.as_ref().unwrap().id, "findChange"); @@ -3087,7 +3100,7 @@ fn confirm_pdf_export_writes_file_and_persists_settings() { sink.lock().unwrap().push((path.to_string(), bytes.to_vec())); Ok(()) })); - app.services.pick_save = Some(Box::new(|_name: &str| Some("/tmp/designcraft-test.pdf".to_string()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some("/tmp/designcraft-test.pdf".into()))); app.ui.dialog = Some(Dialog::new("pdfExport", json!({}))); { @@ -3099,6 +3112,7 @@ fn confirm_pdf_export_writes_file_and_persists_settings() { d.fields.insert("title".into(), json!("Report")); } confirm(&mut app).unwrap(); + app.settle_dialogs(); let got = writes.lock().unwrap(); assert_eq!(got.len(), 1, "confirming the dialog writes exactly one file"); @@ -3160,11 +3174,12 @@ fn confirm_pdf_export_cancelled_save_writes_nothing() { sink.lock().unwrap().push((path.to_string(), bytes.to_vec())); Ok(()) })); - app.services.pick_save = Some(Box::new(|_name: &str| None)); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(None)); app.ui.dialog = Some(Dialog::new("pdfExport", json!({}))); let r = confirm(&mut app).unwrap(); assert_eq!(r, Value::Null); + app.settle_dialogs(); assert!(writes.lock().unwrap().is_empty(), "cancelling the save picker must not write"); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f25ee7ed8..0221aa257 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -61,7 +61,7 @@ use app::browser_import_tests; pub use app::opens_as_document; pub use clock::now_ms; pub use key_names::{drop_key_name_text, is_key_name}; -pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn}; +pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, ReadFn, Services, WriteFn}; pub(crate) use state::second_window_viewport; pub use state::{CanvasCache, HiddenPanels, ImportRequest, ImportedFile, PdfExportSettings, Perf, SavedWorkspace, ScreenMode, Shown, UiState, View}; @@ -120,6 +120,19 @@ struct ShotRequest { pub struct DesignApp { pending_pdf: Option, + /// File and message dialogs: asked for during a frame, shown by the host at its end, answered + /// on a later frame (see `app/file_dialogs.rs`). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) file_dialogs: app::file_dialogs::Dialogs, + /// The dialogs asked for and not yet answered, in order: the same one isn't asked twice. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) asked: Vec, + /// Dialog answers waiting for an egui dialog to close. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) held_answers: Vec, + /// The context the dialogs' wake-up repaints, once the first frame has run. + #[cfg(not(target_arch = "wasm32"))] + dialog_wake: std::sync::Arc>, pub session: Session, pub ui: UiState, pub services: Services, @@ -272,6 +285,8 @@ struct QuitState { original_active: Option, accepted: HashMap, pending: Option, + /// The document whose Save answer went to a save dialog that hasn't answered yet. + saving: Option, } /// Whether CJK-only interface is shown (Preferences › Type › Show CJK Features): the explicit diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b03e2bc38..ffcfa0988 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -31,6 +31,7 @@ pub use keys::{forwarded_shortcuts, shortcuts}; use quick_apply::quick_commands; pub use quick_apply::{QuickItem, command_search, palette, quick_apply_items}; pub use run::run_ui; +pub(crate) use run::save_then; pub use state::{ checked, default_shortcut, enabled, menu_enabled, native_menu_enabled, shortcut_label, shortcut_of, shortcut_text, tool_shortcut_label, }; diff --git a/crates/ui-egui/src/menus/export.rs b/crates/ui-egui/src/menus/export.rs index 56014c25c..2c1946d4f 100644 --- a/crates/ui-egui/src/menus/export.rs +++ b/crates/ui-egui/src/menus/export.rs @@ -1,4 +1,5 @@ use super::{DesignApp, Value, json}; +use crate::app::file_dialogs::{Bound, with_path}; pub(super) fn download_document(app: &mut DesignApp) -> Result { let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).ok_or("no document")?; @@ -10,9 +11,27 @@ pub(super) fn download_document(app: &mut DesignApp) -> Result { app.run("file.saveAs", json!({"path": name})) } +/// Asks where to save `name`, then runs `export` with `p` and the chosen path. +#[track_caller] +fn ask_then_export( + app: &mut DesignApp, + name: &str, + p: Value, + export: impl FnOnce(&mut DesignApp, &Value) -> Result + 'static, +) -> Result { + app.ask_save(name, Bound::Document, move |app, path| export(app, &with_path(&p, "path", path))) +} + pub(super) fn export_png(app: &mut DesignApp, p: &Value) -> Result { let st = app.session.active().ok_or("no document")?; let page = crate::control::page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count())?; + let Some(path) = p.get("path").and_then(Value::as_str).map(str::to_string) else { + // The page in view now is the one exported once the dialog answers. + let name = format!("{}-p{}.png", st.doc.title, page + 1); + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + q["page"] = json!(page); + return ask_then_export(app, &name, q, export_png); + }; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(2.0); let mut r = designcraft_render::Renderer::new(); let img = r @@ -26,14 +45,6 @@ pub(super) fn export_png(app: &mut DesignApp, p: &Value) -> Result Some(s.to_string()), - None => { - let name = format!("{}-p{}.png", st.doc.title, page + 1); - app.pick_save(&name) - } - }; - let Some(path) = path else { return Ok(Value::Null) }; match app.services.write.as_mut() { Some(w) => w(&path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height})), None => Err("no writer".into()), @@ -44,14 +55,10 @@ pub(super) fn export_png(app: &mut DesignApp, p: &Value) -> Result Result { let st = app.session.active().ok_or("no document")?; - let path = match p.get("path").and_then(Value::as_str) { - Some(s) => Some(s.to_string()), - None => { - let name = format!("{}.idml", st.doc.title); - app.pick_save(&name) - } + let Some(path) = p.get("path").and_then(Value::as_str).map(str::to_string) else { + let name = format!("{}.idml", st.doc.title); + return ask_then_export(app, &name, p.clone(), export_idml); }; - let Some(path) = path else { return Ok(Value::Null) }; let r = app.run("file.exportIdml", json!({}))?; let bytes = designcraft_engine::cmd::base64_decode(r["base64"].as_str().unwrap_or_default()); match app.services.write.as_mut() { @@ -74,14 +81,11 @@ pub(crate) fn export_epub(app: &mut DesignApp, p: &Value) -> Result Result { let st = app.session.active().ok_or("no document")?; - let path = match p.get("path").and_then(Value::as_str) { - Some(s) => Some(s.to_string()), - None => { - let name = format!("{}.{ext}", st.doc.title); - app.pick_save(&name) - } + let Some(path) = p.get("path").and_then(Value::as_str).map(str::to_string) else { + let name = format!("{}.{ext}", st.doc.title); + let (ext, cmd) = (ext.to_string(), cmd.to_string()); + return ask_then_export(app, &name, p.clone(), move |app, q| export_bytes(app, q, &ext, &cmd)); }; - let Some(path) = path else { return Ok(Value::Null) }; // The caller's options go to the engine command; the path is ours. let mut params = match p { Value::Object(o) => Value::Object(o.iter().filter(|(k, _)| k.as_str() != "path").map(|(k, v)| (k.clone(), v.clone())).collect()), @@ -161,14 +165,10 @@ pub(super) fn separations_view(app: &mut DesignApp, p: &Value) -> Result Result { let st = app.session.active().ok_or("no document")?; - let path = match p.get("path").and_then(Value::as_str) { - Some(s) => Some(s.to_string()), - None => { - let name = format!("{}.pdf", st.doc.title); - app.pick_save(&name) - } + let Some(path) = p.get("path").and_then(Value::as_str).map(str::to_string) else { + let name = format!("{}.pdf", st.doc.title); + return ask_then_export(app, &name, p.clone(), export_pdf); }; - let Some(path) = path else { return Ok(Value::Null) }; let mut params = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = params.as_object_mut() { o.remove("path"); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 1c6d56366..6a8d797b5 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -4,6 +4,7 @@ use super::state::{is_window_control, window_control_available}; use super::tables::{CLASSIC_GROUPS, TYPOGRAPHY_GROUPS, workspace_open_panel}; use super::tree::recent_label; use super::{DesignApp, ScreenMode, Value, json}; +use crate::app::file_dialogs::{Bound, with_path}; /// Type › Tabs: float the Tabs panel (placed above the selected text frame) or close it. fn toggle_tabs_panel(app: &mut DesignApp) { @@ -16,6 +17,39 @@ fn toggle_tabs_panel(app: &mut DesignApp) { } } +/// Asks for a file to open for `purpose`, then runs the UI command `id` again with `p` and the +/// chosen file under `key`. +#[track_caller] +fn rerun_with_path(app: &mut DesignApp, purpose: &str, id: &str, p: &Value, key: &'static str) -> Result { + let (id, p) = (id.to_string(), p.clone()); + app.ask_open(purpose, Bound::Document, move |app, path| run_ui(app, &id, &with_path(&p, key, path)).unwrap_or(Ok(Value::Null))) +} + +/// File › Save As: asks where, saves there, then runs `after`. +#[track_caller] +fn save_as_dialog(app: &mut DesignApp, after: impl FnOnce(&mut DesignApp) -> Result + 'static) -> Result { + let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).unwrap_or_default(); + app.ask_save(&name, Bound::Document, move |app, path| { + app.run("file.saveAs", json!({"path": path}))?; + after(app) + }) +} + +/// Save the active document (a download in a browser; Save As for one never saved), then run +/// `after` once it is written. A save dialog answers on a later frame; Cancel runs nothing. +#[track_caller] +pub(crate) fn save_then(app: &mut DesignApp, after: impl FnOnce(&mut DesignApp) -> Result + 'static) -> Result { + if app.services.download.is_some() { + download_document(app)?; + return after(app); + } + if app.session.active().is_some_and(|d| d.path.is_some()) { + app.run("file.save", json!({}))?; + return after(app); + } + save_as_dialog(app, after) +} + /// Run a UI command; `None` if `id` isn't one. pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option> { let was_find = app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); @@ -85,7 +119,10 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option app.pick_and_open("open"), + "app.openDialog" => match p.get("path").and_then(Value::as_str) { + Some(path) => app.open_picked("open", path.to_string()), + None => app.pick_and_open("open"), + }, "app.openRecent" => { let index = p.get("index").cloned().unwrap_or(Value::Null); let name = index @@ -96,10 +133,13 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option app.pick_and_open("place"), + "app.placeDialog" => match p.get("path").and_then(Value::as_str) { + Some(path) => app.open_picked("place", path.to_string()), + None => app.pick_and_open("place"), + }, "app.placeWithOptions" => { // Word/RTF Import Options: style mapping and conflicts before the text lands. - let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) else { return Some(Ok(Value::Null)) }; + let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(rerun_with_path(app, "place", id, p, "path")) }; let l = path.to_lowercase(); if !(l.ends_with(".docx") || l.ends_with(".rtf")) { return Some(app.place_file(json!({"path": path}))); @@ -121,24 +161,21 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option { - let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).unwrap_or_default(); - if let Some(path) = app.pick_save(&name) { - return Some(app.run("file.saveAs", json!({"path": path}))); - } - Ok(Value::Null) - } + "app.saveDialog" => match p.get("path").and_then(Value::as_str) { + Some(path) => app.run("file.saveAs", json!({"path": path})), + None => save_as_dialog(app, |_| Ok(Value::Null)), + }, "app.quit" => { app.request_quit(); Ok(Value::Null) } - "app.saveCopyDialog" => { - let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default(); - if let Some(path) = app.pick_save(&name) { - return Some(app.run("file.saveACopy", json!({"path": path}))); + "app.saveCopyDialog" => match p.get("path").and_then(Value::as_str) { + Some(path) => app.run("file.saveACopy", json!({"path": path})), + None => { + let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default(); + app.ask_save(&name, Bound::Document, |app, path| app.run("file.saveACopy", json!({"path": path}))) } - Ok(Value::Null) - } + }, "app.exportPng" => export_png(app, p), "app.exportIdml" => export_idml(app, p), // From the menu, EPUB Export Options first; with a path, straight to the file. @@ -208,21 +245,22 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option export_bytes(app, p, "rtf", "file.exportText"), "app.packageDialog" => { - let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); - match app.pick_save(&name) { - Some(dir) => { - let r = app.run("file.package", json!({"dir": dir})); - if let Ok(v) = &r { - app.status( - crate::i18n::tr(&app.ui.language, "Packaged {count} files into {dir}") - .replace("{count}", &v["files"].as_array().map_or(0, |f| f.len()).to_string()) - .replace("{dir}", v["dir"].as_str().unwrap_or("")), - ); - } - r - } - None => Ok(Value::Null), + let Some(dir) = p.get("dir").and_then(Value::as_str) else { + let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); + let p = p.clone(); + return Some(app.ask_save(&name, Bound::Document, move |app, dir| { + run_ui(app, "app.packageDialog", &with_path(&p, "dir", dir)).unwrap_or(Ok(Value::Null)) + })); + }; + let r = app.run("file.package", json!({"dir": dir})); + if let Ok(v) = &r { + app.status( + crate::i18n::tr(&app.ui.language, "Packaged {count} files into {dir}") + .replace("{count}", &v["files"].as_array().map_or(0, |f| f.len()).to_string()) + .replace("{dir}", v["dir"].as_str().unwrap_or("")), + ); } + r } "app.language" => { let Some(v) = p.get("lang").and_then(Value::as_str) else { return Some(app.run("app.preferences", json!({}))) }; @@ -240,29 +278,24 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option { - let Some(pick) = app.services.pick_open.as_mut() else { return Some(Err("needs a file picker".into())) }; - match pick("place") { - Some(path) => app.run("table.placeGraphic", json!({"path": path})), - None => Ok(Value::Null), - } - } + "app.graphicCell" => match p.get("path").and_then(Value::as_str) { + Some(path) => app.run("table.placeGraphic", json!({"path": path})), + None => rerun_with_path(app, "place", id, p, "path"), + }, "effects.dialog" => { crate::effects_dialog::open(app, p.get("page").and_then(Value::as_str).unwrap_or("transparency")); Ok(Value::Null) } "app.loadSwatches" => { - if let Some(pick) = app.services.pick_open.as_mut() { - return Some(match pick("swatches") { - Some(path) => app.run("swatch.load", json!({"path": path})), - None => Ok(Value::Null), - }); + if let Some(path) = p.get("path").and_then(Value::as_str) { + return Some(app.run("swatch.load", json!({"path": path}))); } let request = app.import_request("swatches"); if let Some(open) = app.services.open_async.as_mut() { open(request); + return Some(Ok(Value::Null)); } - Ok(Value::Null) + app.ask_open("swatches", Bound::Document, |app, path| app.run("swatch.load", json!({"path": path}))) } _ => return None, }) @@ -275,11 +308,12 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option app.pick_styles("character"), "app.loadTextStyles" => app.pick_styles(""), "app.saveSwatches" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(s) => Some(s.to_string()), - None => app.pick_save("Swatches.ase"), + let Some(path) = p.get("path").and_then(Value::as_str).map(str::to_string) else { + let p = p.clone(); + return Some(app.ask_save("Swatches.ase", Bound::Document, move |app, path| { + run_ui(app, "app.saveSwatches", &with_path(&p, "path", path)).unwrap_or(Ok(Value::Null)) + })); }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; let r = match app.run("swatch.save", json!({})) { Ok(r) => r, Err(e) => return Some(Err(e)), diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 7f0d49bc0..5e82f6d3f 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -1957,13 +1957,9 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() { // A two-page document, a saved workspace and non-default preferences, a host with window // controls, and file dialogs that are dismissed. let setup = |context: usize| { - let services = crate::Services { - pick_open: Some(Box::new(|_| None)), - pick_save: Some(Box::new(|_| None)), - clipboard_text: Some(Box::new(|| None)), - ..Default::default() - }; + let services = crate::Services { clipboard_text: Some(Box::new(|| None)), ..Default::default() }; let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(None)); app.custom_titlebar = true; app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0))); app.run("file.new", json!({"pages": 2})).unwrap(); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index eb4094e37..649469f30 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -13,14 +13,14 @@ pub fn shown(app: &DesignApp, id: &str) -> bool { /// UI commands: (id, label, shortcut, params). pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.newDocumentDialog", "New Document…", Some("Cmd+N"), "{}"), - ("app.openDialog", "Open…", Some("Cmd+O"), "{}"), + ("app.openDialog", "Open…", Some("Cmd+O"), "{path?} — without a path, choose the file in a dialog"), ("app.openRecent", "Open Recent File", None, "{index} — file.openRecent, with an alert when the file can't be opened"), - ("app.placeDialog", "Place…", Some("Cmd+D"), "{}"), - ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"), + ("app.placeDialog", "Place…", Some("Cmd+D"), "{path?} — without a path, choose the file in a dialog"), + ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{path?} — without a path, choose where in a dialog"), ("app.save", "Save", Some("Cmd+S"), "{}"), ("app.quit", "Quit DesignCraft", Some("Cmd+Q"), "{} — close the window; each document with unsaved changes asks first"), - ("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{} — choose where to write a copy (the document stays as it is)"), - ("app.loadSwatches", "Load Swatches…", None, "{} — pick a swatch exchange (.ase) file"), + ("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{path?} — choose where to write a copy (the document stays as it is)"), + ("app.loadSwatches", "Load Swatches…", None, "{path?} — pick a swatch exchange (.ase) file"), ( "app.loadParagraphStyles", "Load Paragraph Styles…", @@ -35,7 +35,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{page?: transparency|dropShadow|innerShadow|outerGlow|innerGlow|bevel|satin|feather|directionalFeather|gradientFeather} — the Effects dialog for the selected objects", ), - ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"), + ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{dir?} — choose the package folder to create (file.package)"), ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), @@ -74,9 +74,9 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{preset: \"\"|high|medium|low} — PDF export rasterises spreads with transparency at 300 / 150 / 72 ppi", ), - ("app.graphicCell", "Convert Cell to Graphic Cell…", None, "{} — pick an image for the target table cell"), + ("app.graphicCell", "Convert Cell to Graphic Cell…", None, "{path?} — pick an image for the target table cell"), ("app.placeAndLink", "Place and Link", None, "{} — a linked copy of the selected frame's story, beside it"), - ("app.placeWithOptions", "Place with Import Options…", Some("Cmd+Shift+D"), "{} — Word/RTF style mapping before placing"), + ("app.placeWithOptions", "Place with Import Options…", Some("Cmd+Shift+D"), "{path?} — Word/RTF style mapping before placing"), ("app.exportText", "Export Text…", None, "{path?} — the story being edited, as Text Only (.txt) or Rich Text Format (.rtf)"), ("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"), ("app.palette", "Quick Apply…", Some("Cmd+Return"), "{} — search styles and commands"), diff --git a/crates/ui-egui/src/panels/datamerge.rs b/crates/ui-egui/src/panels/datamerge.rs index e42055aec..8920698c4 100644 --- a/crates/ui-egui/src/panels/datamerge.rs +++ b/crates/ui-egui/src/panels/datamerge.rs @@ -4,6 +4,7 @@ use egui::{Color32, Sense, Stroke, vec2}; use serde_json::{Value, json}; use crate::DesignApp; +use crate::app::file_dialogs::Bound; use crate::theme::{Tokens, semibold}; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -186,11 +187,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } fn select_source(app: &mut DesignApp) { - let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("dataMerge")) else { + let asked = app.ask_open("dataMerge", Bound::Document, |app, path| { + let _ = app.run("data.source.select", json!({"path": path})); + Ok(Value::Null) + }); + if asked.is_err() { app.status(tr(&app.ui.language, "Pass csv, rows, or bytes from the control channel.")); - return; - }; - let _ = app.run("data.source.select", json!({"path": path})); + } } /// Render a localized, named combo and return only a changed selection. diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 5c7ae92ed..4d5558ffb 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -3,6 +3,7 @@ use serde_json::{Value, json}; use crate::DesignApp; +use crate::app::file_dialogs::Bound; use crate::theme::Tokens; fn dest_text(lang: &str, d: &Value) -> String { @@ -230,13 +231,10 @@ pub fn tags(app: &mut DesignApp, ui: &mut egui::Ui) { let vid = egui::Id::unique("dtd_problems"); let mut problems: Option> = ui.data(|d| d.get_temp(vid)); ui.horizontal(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load DTD…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("dtd")) - { - match app.run("xml.loadDtd", json!({"path": path})) { - Ok(_) => problems = None, - Err(e) => app.status_error("Load DTD", e), - } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load DTD…"))).clicked() { + // A new DTD's problems show at the next validation. + problems = None; + ask_then_run(app, "dtd", "Load DTD", |path| ("xml.loadDtd", json!({"path": path}))); } let has_dtd = info["dtd"].as_bool().unwrap_or(false); if ui.add_enabled(has_dtd, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Validate")))).clicked() @@ -280,15 +278,28 @@ pub fn tags(app: &mut DesignApp, ui: &mut egui::Ui) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export XML…"))).clicked() { let _ = app.run("app.exportXml", json!({})); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import XML…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("xml")) - && let Err(e) = app.run("file.importXml", json!({"path": path})) - { - app.status_error("Import XML", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import XML…"))).clicked() { + ask_then_run(app, "xml", "Import XML", |path| ("file.importXml", json!({"path": path}))); } }); } +/// Asks for a file for `purpose`, then runs the command `command(path)` names; a failure shows +/// in the status line as `feature`'s. +#[track_caller] +fn ask_then_run(app: &mut DesignApp, purpose: &str, feature: &'static str, command: impl FnOnce(String) -> (&'static str, Value) + 'static) { + let asked = app.ask_open(purpose, Bound::Document, move |app, path| { + let (id, params) = command(path); + if let Err(e) = app.run(id, params) { + app.status_error(feature, e); + } + Ok(Value::Null) + }); + if let Err(e) = asked { + app.status_error(feature, e); + } +} + /// Object States panel: the selected multi-state object's states (click to show one). pub fn states(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); @@ -638,11 +649,8 @@ pub fn media(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Poster")); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Choose Image…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) - && let Err(e) = app.run("media.options", json!({"poster": {"path": path}})) - { - app.status_error("Media", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Choose Image…"))).clicked() { + ask_then_run(app, "place", "Media", |path| ("media.options", json!({"poster": {"path": path}}))); } if !m["poster"].is_null() && ui.small_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "None"))).clicked() { let _ = app.run("media.options", json!({"poster": null})); diff --git a/crates/ui-egui/src/panels/library.rs b/crates/ui-egui/src/panels/library.rs index 689b43ffa..2b19770cf 100644 --- a/crates/ui-egui/src/panels/library.rs +++ b/crates/ui-egui/src/panels/library.rs @@ -4,6 +4,7 @@ use serde_json::json; use crate::DesignApp; +use crate::app::file_dialogs::Bound; use crate::theme::Tokens; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -33,14 +34,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.separator(); ui.horizontal(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Library…"))).clicked() { - let path = app.pick_save("Library.dclib"); - let _ = app.run("library.new", json!({"path": path})); + let asked = app.ask_save("Library.dclib", Bound::App, |app, path| app.run("library.new", json!({"path": path}))); + report(app, "Library", asked); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("library")) - && let Err(e) = app.run("library.open", json!({"path": path})) - { - app.status_error("Library", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked() { + let asked = app.ask_open("library", Bound::App, |app, path| { + app.run("library.open", json!({"path": path})).or_else(|e| report_err(app, "Library", e)) + }); + report(app, "Library", asked); } }); let Ok(items) = app.session.execute("library.list", &json!({})) else { @@ -84,30 +85,57 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// A dialog that couldn't be asked for, in the status line as `feature`'s error. +fn report(app: &mut DesignApp, feature: &str, asked: Result) { + if let Err(e) = asked { + app.status_error(feature, e); + } +} + +/// A command run with a dialog's answer failed: the status line names `feature`. +fn report_err(app: &mut DesignApp, feature: &str, e: String) -> Result { + app.status_error(feature, e); + Ok(serde_json::Value::Null) +} + +/// Scripts › Load…: the chosen script becomes a new, selected one. Read through the host's +/// reader, like every other file the UI opens: this crate has no file system of its own. +fn load_script(app: &mut DesignApp, path: &str, sel_id: egui::Id) { + let read = app.services.read.as_mut().map_or_else(|| Err("no file access".to_string()), |r| r(path)); + match read.and_then(|bytes| String::from_utf8(bytes).map_err(|e| e.to_string())) { + Ok(text) => { + let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Script".into()); + app.ui.scripts.push((name, text)); + let sel = app.ui.scripts.len().saturating_sub(1); + if let Some(ctx) = &app.egui_ctx { + ctx.data_mut(|d| d.insert_temp(sel_id, sel)); + } + } + Err(e) => app.status_error("Scripts", e), + } +} + fn book_open_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Book…"))).clicked() - && let Some(path) = app.pick_save("Book.dcbook") - && let Err(e) = app.run("book.new", json!({"path": path})) - { - app.status_error("Book", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Book…"))).clicked() { + let asked = app + .ask_save("Book.dcbook", Bound::App, |app, path| app.run("book.new", json!({"path": path})).or_else(|e| report_err(app, "Book", e))); + report(app, "Book", asked); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("book")) - && let Err(e) = app.run("book.open", json!({"path": path})) - { - app.status_error("Book", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked() { + let asked = + app.ask_open("book", Bound::App, |app, path| app.run("book.open", json!({"path": path})).or_else(|e| report_err(app, "Book", e))); + report(app, "Book", asked); } }); } fn book_action_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add Document…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("open")) - && let Err(e) = app.run("book.add", json!({"path": path})) - { - app.status_error("Book", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add Document…"))).clicked() { + let asked = + app.ask_open("open", Bound::App, |app, path| app.run("book.add", json!({"path": path})).or_else(|e| report_err(app, "Book", e))); + report(app, "Book", asked); } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Update Numbering"))).clicked() { let _ = app.run("book.paginate", json!({})); @@ -115,11 +143,11 @@ fn book_action_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Synchronize"))).clicked() { let _ = app.run("book.syncStyles", json!({})); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export PDF…"))).clicked() - && let Some(path) = app.pick_save("Book.pdf") - && let Err(e) = app.run("book.exportPdf", json!({"path": path})) - { - app.status_error("Book", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export PDF…"))).clicked() { + let asked = app.ask_save("Book.pdf", Bound::App, |app, path| { + app.run("book.exportPdf", json!({"path": path})).or_else(|e| report_err(app, "Book", e)) + }); + report(app, "Book", asked); } }); } @@ -200,20 +228,12 @@ pub fn scripts(app: &mut DesignApp, ui: &mut egui::Ui) { app.ui.scripts.push((format!("Script {}", app.ui.scripts.len() + 1), "# command.id {json} per line\n".into())); sel = app.ui.scripts.len() - 1; } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("script")) - { - // Through the host's reader, like every other file the UI opens: this crate has no - // file system of its own. - let read = app.services.read.as_mut().map_or_else(|| Err("no file access".to_string()), |r| r(&path)); - match read.and_then(|bytes| String::from_utf8(bytes).map_err(|e| e.to_string())) { - Ok(text) => { - let name = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Script".into()); - app.ui.scripts.push((name, text)); - sel = app.ui.scripts.len() - 1; - } - Err(e) => app.status_error("Scripts", e), - } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load…"))).clicked() { + let asked = app.ask_open("script", Bound::App, move |app, path| { + load_script(app, &path, sel_id); + Ok(serde_json::Value::Null) + }); + report(app, "Scripts", asked); } if ui .add_enabled(!app.ui.scripts.is_empty(), egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete")))) diff --git a/crates/ui-egui/src/panels/properties/links.rs b/crates/ui-egui/src/panels/properties/links.rs index 5084b6cf1..8d13fe06d 100644 --- a/crates/ui-egui/src/panels/properties/links.rs +++ b/crates/ui-egui/src/panels/properties/links.rs @@ -1,4 +1,5 @@ use super::{DesignApp, Sense, Tokens, Value, divider, json, sel_info, units, vec2}; +use crate::app::file_dialogs::Bound; /// Links: status (missing / modified / embedded), page and effective resolution of every placed /// graphic and linked text file; Relink, Go To Link, Update Link, Embed Link for the chosen row. @@ -143,10 +144,15 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { p[k] = v; p }; - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Relink…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|pick| pick("relink")) - { - let _ = app.run("links.relink", with("path", json!(path))); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Relink…"))).clicked() { + let target = with("path", Value::Null); + let asked = app.ask_open("relink", Bound::Document, move |app, path| { + let _ = app.run("links.relink", crate::app::file_dialogs::with_path(&target, "path", path)); + Ok(Value::Null) + }); + if let Err(e) = asked { + app.status_error("Relink", e); + } } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Go To"))).clicked() { let _ = app.run("links.goTo", target.clone()); @@ -178,12 +184,16 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pick a file in the folder holding the moved graphics")); }) .clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|pick| pick("relink")) - && let Some(dir) = std::path::Path::new(&path).parent() { - let r = app.run("links.relinkFolder", json!({"dir": dir.to_string_lossy()})); - if let Ok(v) = r { - app.status(crate::i18n::tr(&app.ui.language, "Relinked {count}").replace("{count}", &v["relinked"].to_string())); + let asked = app.ask_open("relink", Bound::Document, |app, path| { + let Some(dir) = std::path::Path::new(&path).parent() else { return Ok(Value::Null) }; + if let Ok(v) = app.run("links.relinkFolder", json!({"dir": dir.to_string_lossy()})) { + app.status(crate::i18n::tr(&app.ui.language, "Relinked {count}").replace("{count}", &v["relinked"].to_string())); + } + Ok(Value::Null) + }); + if let Err(e) = asked { + app.status_error("Relink", e); } } }); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index d7e88969c..b413e90c4 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -4,8 +4,6 @@ pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; -pub type PickFn = Box Option>; - pub type OpenAsyncFn = Box; /// Starts a browser download; an error means no file was handed to the user. @@ -16,21 +14,18 @@ pub type ClipboardFn = Box Option>; /// Files delivered asynchronously with their original operation and document identity. pub type Inbox = std::sync::Arc>>; -/// Platform services injected by the host (desktop or web). +/// Platform services injected by the host (desktop or web). File dialogs aren't among them: the +/// app asks for them itself and the host shows them (`DesignApp::show_file_dialogs`). #[derive(Default)] pub struct Services { - /// Open-file dialog for a purpose (`open`, `place`) → path. - pub pick_open: Option, - /// Save dialog with a suggested name → path. The name may start with the folder the dialog - /// opens in (see [`DesignApp::pick_save`]). - pub pick_save: Option, pub read: Option, pub write: Option, /// Asynchronous open dialog for a purpose (`open`, `place`); the chosen file arrives later - /// through [`Services::inbox`]. Used when `pick_open` is unset (web). + /// through [`Services::inbox`]. In a browser, which has no native dialogs. pub open_async: Option, /// Hand bytes to the user as a named file (browser download). When set, Save uses it - /// instead of writing to a path. + /// instead of writing to a path, and a file saved under a name goes to the downloads with no + /// save dialog. pub download: Option, /// Files delivered asynchronously, drained every frame with their captured request context. pub inbox: Option, diff --git a/crates/ui-egui/src/shortcuts_dialog.rs b/crates/ui-egui/src/shortcuts_dialog.rs index 797b0cb71..95f8eb10c 100644 --- a/crates/ui-egui/src/shortcuts_dialog.rs +++ b/crates/ui-egui/src/shortcuts_dialog.rs @@ -14,9 +14,10 @@ use std::borrow::Cow; use craft_keymap::ui::{DialogState, Format, Options, Outcome, ShortcutsDialog, Text}; use craft_keymap::{Bindable, Overrides}; -use serde_json::json; +use serde_json::{Value, json}; use crate::DesignApp; +use crate::app::file_dialogs::Bound; use crate::i18n::tr; /// The `app.ui.dialog` id while the dialog is open. @@ -28,7 +29,7 @@ pub static BUILTINS: [(&str, Overrides); 1] = [("Default", Overrides::new())]; /// The name edits of the built-in set are saved under (translated, made unique). pub const CUSTOM_SET: &str = "Custom"; -/// The file-picker purpose of the dialog's Import (`Services::pick_open`, `open_async`). +/// The file-picker purpose of the dialog's Import (`DesignApp::ask_open`, `open_async`). pub const PURPOSE: &str = "shortcuts"; /// A set saved by InDesign's Show Set, read by `shortcut_import`. @@ -176,20 +177,26 @@ pub(crate) fn close(app: &mut DesignApp, ok: bool) -> Result<(), String> { Ok(()) } -/// Import: a native picker reads the file at once; in a browser it arrives later through -/// [`deliver`]. +/// Import: the chosen file is read when the open dialog answers, or arrives later through +/// [`deliver`] in a browser. fn request_import(app: &mut DesignApp, host: &mut Host) { - if let Some(pick) = app.services.pick_open.as_mut() { - let Some(path) = pick(PURPOSE) else { return }; - match crate::shortcut_import::read_file(&path) { - Ok(text) => review(app, host, &text), - Err(e) => host.state.show_error(e), - } - return; - } let request = app.import_request(PURPOSE); if let Some(open) = app.services.open_async.as_mut() { open(request); + return; + } + let asked = app.ask_open(PURPOSE, Bound::App, |app, path| { + // The dialog closed meanwhile: nothing to import into. + let Some(mut host) = app.shortcut_dialog.take() else { return Ok(Value::Null) }; + match crate::shortcut_import::read_file(&path) { + Ok(text) => review(app, &mut host, &text), + Err(e) => host.state.show_error(e), + } + app.shortcut_dialog = Some(host); + Ok(Value::Null) + }); + if let Err(e) = asked { + host.state.show_error(e); } } @@ -228,14 +235,20 @@ fn review(app: &mut DesignApp, host: &mut Host, text: &str) { /// Writes a file the dialog made where the user chooses (a download on the web). fn save_file(app: &mut DesignApp, name: &str, bytes: &[u8]) { let name: String = name.chars().map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':') { '_' } else { c }).collect(); - let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) else { return }; - let result = match app.services.write.as_mut() { - Some(write) => write(&path, bytes), - None => Err("no writer".into()), - }; - match result { - Ok(()) => app.status(tr(&app.ui.language, "Saved to {path}").replace("{path}", &path)), - Err(e) => app.status(e), + let bytes = bytes.to_vec(); + let asked = app.ask_save(&name, Bound::App, move |app, path| { + let result = match app.services.write.as_mut() { + Some(write) => write(&path, &bytes), + None => Err("no writer".into()), + }; + match result { + Ok(()) => app.status(tr(&app.ui.language, "Saved to {path}").replace("{path}", &path)), + Err(e) => app.status(e), + } + Ok(Value::Null) + }); + if let Err(e) = asked { + app.status(e); } } @@ -424,10 +437,12 @@ mod tests { .unwrap(); let picked = path.to_string_lossy().to_string(); let mut app = app(); - app.services.pick_open = Some(Box::new(move |purpose| (purpose == PURPOSE).then(|| picked.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked))); app.run("app.keyboardShortcuts", json!({})).unwrap(); let mut h = harness(app); click(&mut h, "Import…"); + h.state_mut().settle_dialogs(); + h.run(); assert!(has(&h, "Import “Studio”")); assert!(h.state().ui.shortcuts.is_empty(), "the review changes nothing"); click(&mut h, "Import as New Set"); diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index d554cf0dc..36bac93a9 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -78,26 +78,33 @@ fn save_dialogs_start_beside_the_document_or_in_the_last_folder() { let answer: Rc>> = Rc::default(); let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); let (seen, reply) = (asked.clone(), answer.clone()); - app.services.pick_save = Some(Box::new(move |name: &str| { - seen.borrow_mut().push(name.to_string()); - reply.borrow().clone() - })); + app.answer_dialogs(move |request| { + let craft_dialogs::Request::File(dialog) = request else { return craft_dialogs::Answer::Cancelled }; + let name = dialog.file_name.clone().unwrap_or_default(); + seen.borrow_mut().push(dialog.directory.as_ref().map_or_else(|| name.clone(), |folder| folder.join(&name).to_string_lossy().to_string())); + reply.borrow().clone().map_or(craft_dialogs::Answer::Cancelled, |path| craft_dialogs::Answer::Paths(vec![path.into()])) + }); app.services.write = Some(Box::new(|_: &str, _: &[u8]| Ok(()))); let last = |asked: &Rc>>| asked.borrow().last().cloned().unwrap_or_default(); let export_pdf = |app: &mut DesignApp| { app.ui.dialog = Some(dialogs::Dialog::new("pdfExport", json!({}))); app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Report")); dialogs::confirm(app).unwrap(); + app.settle_dialogs(); + }; + let save_dialog = |app: &mut DesignApp| { + app.run("app.saveDialog", json!({})).unwrap(); + app.settle_dialogs(); }; app.run("file.new", json!({})).unwrap(); let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title); - app.run("app.saveDialog", json!({})).unwrap(); + save_dialog(&mut app); assert_eq!(last(&asked), untitled, "never saved, nothing remembered: the system's own folder"); let saved = home.path().join("Brochure.designcraft"); *answer.borrow_mut() = Some(saved.to_string_lossy().to_string()); - app.run("app.saveDialog", json!({})).unwrap(); + save_dialog(&mut app); assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*saved.to_string_lossy())); // An export into another folder becomes the last folder used. *answer.borrow_mut() = Some(elsewhere.path().join("Proof.pdf").to_string_lossy().to_string()); @@ -108,7 +115,7 @@ fn save_dialogs_start_beside_the_document_or_in_the_last_folder() { app.run("file.new", json!({})).unwrap(); let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title); - app.run("app.saveDialog", json!({})).unwrap(); + save_dialog(&mut app); assert_eq!(last(&asked), elsewhere.path().join(untitled).to_string_lossy(), "a new document starts in the last folder used"); } @@ -116,19 +123,11 @@ fn save_dialogs_start_beside_the_document_or_in_the_last_folder() { /// title with a slash moved the suggestion out of the document's folder. #[test] fn save_dialog_suggestions_keep_the_folder_and_only_a_file_name() { - use std::{cell::RefCell, rc::Rc}; let folder = tempfile::tempdir().unwrap(); - let asked: Rc>> = Rc::default(); let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); - let seen = asked.clone(); - app.services.pick_save = Some(Box::new(move |name: &str| { - seen.borrow_mut().push(name.to_string()); - None - })); app.ui.last_save_folder = Some(folder.path().join("Docs").to_string_lossy().to_string()); - app.pick_save(""); - app.pick_save("Q1/Q2.pdf"); - assert_eq!(*asked.borrow(), ["".to_string(), folder.path().join("Docs").join("Q2.pdf").to_string_lossy().to_string()]); + assert_eq!(app.suggested_save_path(""), ""); + assert_eq!(app.suggested_save_path("Q1/Q2.pdf"), folder.path().join("Docs").join("Q2.pdf").to_string_lossy()); } /// An app with one document that has an unsaved edit. @@ -379,12 +378,13 @@ fn repeated_native_quit_requests_save_and_discard_only_after_all_answers() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("saved.designcraft"); let chosen = path.to_string_lossy().to_string(); - app.services.pick_save = Some(Box::new(move |_| Some(chosen.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(chosen.clone()))); app.session.recovery_dir = Some(directory.path().join("recovery")); app.run("file.recovery.save", json!({})).unwrap(); quit_frame(&mut app, &ctx, true); assert_eq!(dialog_uid(&app), Some(uids[0])); dialogs::confirm(&mut app).unwrap(); // Save the first document. + app.settle_dialogs(); assert!(!app.session.documents()[0].is_dirty()); quit_frame(&mut app, &ctx, false); assert_eq!(dialog_uid(&app), Some(uids[1])); @@ -442,11 +442,13 @@ fn quitting_save_failure_after_discard_keeps_documents_and_can_retry() { .join("document.designcraft"); assert!(!path.parent().unwrap().exists()); let picked = path.to_string_lossy().to_string(); - app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); quit_frame(&mut app, &ctx, true); discard_quit_dialog(&mut app); quit_frame(&mut app, &ctx, false); - assert!(dialogs::confirm(&mut app).is_err()); + // The folder is missing: saving there fails and Quit stops. + dialogs::confirm(&mut app).unwrap(); + app.settle_dialogs(); assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); assert_eq!(app.session.documents().len(), 2); assert!(app.session.documents().iter().all(|d| d.is_dirty())); @@ -455,6 +457,37 @@ fn quitting_save_failure_after_discard_keeps_documents_and_can_retry() { assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid)); } +/// Quit › Save on a document never saved, then Cancel in the save dialog: Quit stops and the +/// document stays open with its changes. +#[test] +fn quit_save_cancelled_in_the_save_dialog_stops_quitting() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(None)); + quit_frame(&mut app, &ctx, true); + dialogs::confirm(&mut app).unwrap(); // Save + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close), "Quit waits for the save dialog"); + assert!(app.quitting.is_some()); + app.settle_dialogs(); + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(app.quitting.is_none(), "Quit stopped"); + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.documents()[0].is_dirty()); +} + +/// Close › Save on a document never saved, then Cancel in the save dialog: the document stays +/// open with its changes. +#[test] +fn close_save_cancelled_in_the_save_dialog_keeps_the_document_open() { + let mut app = app_with_unsaved_document(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(None)); + crate::menus::close_document(&mut app, Some(0)); + dialogs::confirm(&mut app).unwrap(); // Save + app.settle_dialogs(); + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.documents()[0].is_dirty()); +} + #[test] fn quitting_save_then_later_cancel_keeps_saved_document_open() { let ctx = egui::Context::default(); @@ -463,9 +496,10 @@ fn quitting_save_then_later_cancel_keeps_saved_document_open() { let first = app.session.documents()[0].uid; let path = std::env::temp_dir().join(format!("designcraft-quit-save-{}-{first}.designcraft", std::process::id())); let picked = path.to_string_lossy().to_string(); - app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); quit_frame(&mut app, &ctx, true); dialogs::confirm(&mut app).unwrap(); + app.settle_dialogs(); quit_frame(&mut app, &ctx, false); assert_eq!(app.session.documents().len(), 2); assert!(!app.session.documents()[0].is_dirty()); @@ -516,10 +550,11 @@ fn quitting_pending_save_uses_dialog_uid_after_tab_switch() { let first = app.session.documents()[0].uid; let path = std::env::temp_dir().join(format!("designcraft-quit-uid-{}-{first}.designcraft", std::process::id())); let picked = path.to_string_lossy().to_string(); - app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); quit_frame(&mut app, &ctx, true); app.run("file.activate", json!({"index": 1})).unwrap(); dialogs::confirm(&mut app).unwrap(); + app.settle_dialogs(); assert_eq!(app.session.documents()[0].path.as_deref(), path.to_str()); assert!(!app.session.documents()[0].is_dirty()); assert!(app.session.documents()[1].is_dirty()); @@ -839,16 +874,19 @@ fn a_file_dropped_while_a_parent_is_open_lands_on_the_parent() { fn file_open_failures_show_an_alert() { let path = unreadable_file("open.designcraft"); let picked = path.to_string_lossy().to_string(); - let services = Services { pick_open: Some(Box::new(move |_| Some(picked.clone()))), ..Default::default() }; - let mut app = DesignApp::new(Session::new(), services); - let e = app.run("app.openDialog", json!({})).unwrap_err(); - assert_eq!(alert(&app), ("Can't Open the File".to_string(), e)); + let mut app = DesignApp::new(Session::new(), Services::default()); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked))); + // The error comes with the dialog's answer, on a later frame: the alert and the status line. + app.run("app.openDialog", json!({})).unwrap(); + app.settle_dialogs(); + assert_eq!(alert(&app), ("Can't Open the File".to_string(), app.ui.status.clone())); // Placing it says so too. app.ui.dialog = None; app.run("file.new", json!({})).unwrap(); - let e = app.run("app.placeDialog", json!({})).unwrap_err(); + app.run("app.placeDialog", json!({})).unwrap(); + app.settle_dialogs(); let _ = std::fs::remove_file(&path); - assert_eq!(alert(&app), ("Can't Place the File".to_string(), e)); + assert_eq!(alert(&app), ("Can't Place the File".to_string(), app.ui.status.clone())); } #[test] From ed88f3e4b5f6775a92e2ab4a2e7fa7d993477ff6 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:52:25 -0600 Subject: [PATCH 34/48] Compose: tables clear the footnote area Reserve column and spanning footnotes when fitting inline table row groups. Continue rows in the next column or frame when the remaining space is too small, and include cell-local footnotes in measured row height. Fixes #1901 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 + crates/compose/src/lib.rs | 15 +++- crates/compose/src/table.rs | 29 ++++-- crates/compose/src/tests.rs | 172 ++++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..7cc6acf73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-28** · Inline table rows leave room for column and spanning footnotes, continuing in + the next column or frame when needed (#1901). Cell-local footnotes contribute to row height + and sit inside the cell rather than outside its measuring frame. Vertical justification keeps + paragraphs and table fragments above the footnote area. No readiness percentage change. - **2026-10-25** · The application bar uses craft-window's shared title-bar code. A drag that starts on a floating panel over the bar moves the panel, not the window, and in full screen the bar no longer zooms or moves the window. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 88becd85f..58454b228 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1099,7 +1099,7 @@ fn place_table_paragraph(input: TableParagraphInput<'_>) -> TableParagraphResult } let at_top = cursor.last_baseline.is_none(); let start_at = (cursor.fi, cursor.col); - if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, opts) { + if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, notes, opts) { if let Some(j) = trial_failed(trial, limits, *balance_runs < balance_budget) { *balance_runs += 1; rewind(j, snaps, note_snaps, notes, out, cursor, list_counter, force_col, line_cap, no_hyph); @@ -3108,7 +3108,7 @@ fn finish_composition(input: FinishInput<'_>) -> ComposedStory { let before: Vec = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect(); let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)) || ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true)); - vertical_justify(ft, f, spanned); + vertical_justify(ft, f, spanned, &doc.footnote_options); for (t, b) in ft.tables.iter_mut().zip(before) { let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); t.shift(now - b); @@ -5320,7 +5320,7 @@ fn hyphenation_points( } /// `spanned`: a paragraph spans the frame's columns, so the lines move together. -fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { +fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { return; } @@ -5333,7 +5333,14 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect(); let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue }; let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max); - let space = f.area.y1 - bottom; + let bottom = ft.tables.iter().filter(|t| spanned || t.column == c).map(|t| t.rect.y1).fold(bottom, f64::max); + let limit = ft + .notes + .iter() + .filter(|n| spanned || notes.span_columns || n.column == c) + .map(|n| n.rect.y0 - notes.space_before) + .fold(f.area.y1, f64::min); + let space = limit - bottom; if space <= 0.0 { continue; } diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index c620622a3..dc579d8a7 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -122,7 +122,7 @@ impl CellLineExtent { /// Compose a cell story at width `w` (content space, top-left at the origin). fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout { - let spec = FrameSpec { + let mut spec = FrameSpec { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), opts: TextFrameOptions { first_baseline: cell.first_baseline, first_baseline_min: cell.first_baseline_min, ..TextFrameOptions::default() }, @@ -137,7 +137,15 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef right_page, page_rect: None, }; - let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + let mut cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + // The measuring frame is deliberately tall. Recompose at the actual content height so + // cell-local footnotes sit inside the row rather than at the measuring frame's bottom. + if let Some(frame) = cs.frames.first().filter(|f| !f.notes.is_empty()) { + let body = frame.lines.iter().map(|l| l.baseline + l.descent).fold(0.0, f64::max); + let note_top = frame.notes.first().map_or(spec.area.y1, |n| n.rect.y0); + spec.area.y1 = body + doc.footnote_options.space_before + (spec.area.y1 - note_top); + cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + } let line_heights: Vec = cs .frames .first() @@ -157,7 +165,8 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef .unwrap_or_default(); // Check all lines: a large descender or lowered inline object on an earlier line can // extend below the final baseline when leading is fixed. - let content_h = line_heights.iter().copied().fold(0.0, f64::max); + let note_bottom = cs.frames.first().map_or(0.0, |f| f.notes.iter().map(|n| n.rect.y1).fold(0.0, f64::max)); + let content_h = line_heights.iter().copied().fold(note_bottom, f64::max); CellLayout { text: Arc::new(cs), content_h, line_heights } } @@ -174,6 +183,7 @@ pub(crate) fn place_table( cols: &[Vec], cur: &mut Cursor, out: &mut ComposedStory, + notes: &mut crate::notes::Notes, opts: &ComposeOptions, ) -> bool { if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 { @@ -292,6 +302,13 @@ pub(crate) fn place_table( } }; place_rows(0..header, &mut y, false, &mut frag_rows); + // Table rows do not add story footnotes; cell-local notes are composed separately. + let column_reserve = |notes: &mut crate::notes::Notes, cur: &Cursor, col: Rect| { + let note_w = notes.area(&cols[cur.fi], cur.col).map_or(col.width(), |a| a.width()); + let column = crate::notes::NoteColumn { frame_index: cur.fi, column_index: cur.col, width: note_w, frame: &frames[cur.fi] }; + notes.reserve(doc, column, &[], opts) + }; + let mut reserve = column_reserve(notes, cur, col); let mut r = header; loop { if r >= body_end { @@ -299,9 +316,10 @@ pub(crate) fn place_table( } let ge = group_end[r].min(body_end).max(r + 1); let gh = sum(r, ge); + let bottom = col.y1 - reserve; let at_col_top = cur.last_baseline.is_none() && (top - col.y0).abs() < 1e-6; - if y + gh + footer_h <= col.y1 + 0.01 || (body_placed == 0 && at_col_top) { - if y + gh + footer_h > col.y1 + 0.01 && body_placed == 0 && at_col_top && y + gh > col.y1 + 0.01 { + if y + gh + footer_h <= bottom + 0.01 || (body_placed == 0 && at_col_top && reserve == 0.0) { + if y + gh + footer_h > bottom + 0.01 && body_placed == 0 && at_col_top && y + gh > bottom + 0.01 { // A row group taller than an empty column can never fit. return false; } @@ -324,6 +342,7 @@ pub(crate) fn place_table( return false; } col = col_rect(cur); + reserve = column_reserve(notes, cur, col); y = col.y0; top = y; frag_rows.clear(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3083261fb..2f66eca93 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1994,6 +1994,178 @@ fn footnotes_sit_at_the_column_bottom_and_push_text() { assert!(note_caret(&cs, n.id, 0).is_some()); } +fn justified_content_clears_footnotes(mode: designcraft_doc::VerticalJustification, table: bool) { + for span in [false, true] { + let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 240.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.footnote_options.span_columns = span; + d.footnote_options.space_before = 60.0; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic footnote.", ParaFormat::default()); + if table { + let mut t = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + t.rows[0].height = 90.0; + st.insert_table(st.len(), t); + } + let top = compose_story(&d, sid, &ComposeOptions::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = mode; + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(!cs.is_overset()); + let ft = cs.frame(fid).unwrap(); + let original = top.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 1); + assert_eq!(ft.notes[0].rect, original.notes[0].rect, "footnotes must not move"); + let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before; + assert!(ft.lines.iter().all(|l| l.baseline + l.descent <= limit + 0.01), "lines overlap footnotes in {mode:?}, span {span}"); + assert!(ft.tables.iter().flat_map(|t| &t.cells).all(|c| c.rect.y1 <= limit + 0.01), "cells overlap footnotes in {mode:?}, span {span}"); + assert!(ft.lines.last().unwrap().baseline > original.lines.last().unwrap().baseline + 0.01, "content must still move down"); + if table { + assert_eq!(ft.tables.len(), 1); + assert!(ft.tables[0].rect.y1 > original.tables[0].rect.y1 + 0.01, "table must still move down"); + } + } +} + +#[test] +fn bottom_justified_paragraphs_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, false); +} + +#[test] +fn bottom_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, true); +} + +#[test] +fn center_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Center, true); +} + +#[test] +fn vertically_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Justify, true); +} + +#[test] +fn vertical_justification_counts_table_fragment_bottoms() { + use designcraft_doc::VerticalJustification::{Bottom, Center, Justify}; + let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 200.0, 240.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + table.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + table.rows[0].height = 120.0; + st.insert_table(st.len(), table); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + for mode in [Bottom, Center, Justify] { + let mut ft = cs.frame(fid).unwrap().clone(); + let t = &ft.tables[0]; + let (anchor, bottom, baseline) = (t.line, t.rect.y1, t.rect.y0); + // Exercise a fragment extending below its anchor, without a later text line hiding it. + ft.lines.truncate(anchor + 1); + ft.lines[anchor].baseline = baseline; + let mut spec = frame_specs(&d, sid).remove(0); + spec.opts.vertical_justification = mode; + + vertical_justify(&mut ft, &spec, false, &d.footnote_options); + + let moved = ft.lines[anchor].baseline - baseline; + let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before; + assert!(moved > 0.01); + assert!(bottom + moved <= limit + 0.01, "fragment overlaps footnotes in {mode:?}"); + } +} + +#[test] +fn table_rows_clear_footnotes_and_continue_in_the_next_column() { + for span in [false, true] { + for row_height in [24.0, 150.0] { + let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.footnote_options.span_columns = span; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "First synthetic footnote.", ParaFormat::default()); + st.insert_note(25, "Second synthetic footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 8, 1, 0, 0, 180.0); + for row in &mut table.rows { + row.mode = designcraft_doc::RowHeightMode::Exactly; + row.height = row_height; + } + st.insert_table(st.len(), table); + let (next, _) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 220.0, 420.0, 1720.0), d.default_layer(), "", ParaFormat::default()).unwrap(); + d.thread(fid, next).unwrap(); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(!cs.is_overset()); + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 2); + let top = ft.notes[0].rect.y0 - d.footnote_options.space_before; + for frag in &ft.tables { + if span || frag.column == 0 { + assert!(frag.cells.iter().all(|c| c.rect.y1 <= top + 0.01), "rows overlap footnotes: {:?}, top {top}", frag.rect); + } + } + assert!(cs.frames.iter().skip(1).any(|f| !f.tables.is_empty()) || ft.tables.iter().any(|t| t.column == 1)); + let rows: Vec<_> = cs.frames.iter().flat_map(|f| &f.tables).flat_map(|t| &t.cells).filter(|c| !c.repeated).map(|c| c.row).collect(); + assert_eq!(rows, (0..8).collect::>()); + if row_height == 150.0 { + assert!(!ft.tables.iter().any(|t| t.column == 0), "the whole table moves when no row fits"); + } + } + } +} + +#[test] +fn table_reports_overset_when_every_remaining_column_has_footnotes() { + let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 3; + d.footnote_options.span_columns = true; + d.footnote_options.space_before = 40.0; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic spanning footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 2, 1, 0, 0, 100.0); + for row in &mut table.rows { + row.mode = designcraft_doc::RowHeightMode::Exactly; + row.height = 90.0; + } + table.cells[0].row_span = 2; + let anchor = st.insert_table(st.len(), table); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(cs.is_overset()); + assert_eq!(cs.overset_at, Some(anchor)); + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 1); + assert!(ft.notes[0].rect.y0 - d.footnote_options.space_before < 180.0); + assert!(cs.frames.iter().all(|f| f.tables.is_empty()), "the unplaced row group must remain overset"); +} + +#[test] +fn table_cell_footnotes_fit_inside_the_row() { + let (mut d, sid, _) = doc_with("", Rect::new(0.0, 0.0, 200.0, 200.0), ParaAttrs::default()); + let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + let cell = &mut table.cells[0]; + cell.text.insert(0, "Cell reference."); + cell.text.insert_note(5, "Synthetic cell footnote.", ParaFormat::default()); + d.story_mut(sid).unwrap().insert_table(0, table); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + let cell = &cs.frames[0].tables[0].cells[0]; + assert!(!cell.overset); + let frame = &cell.text.frames[0]; + assert_eq!(frame.notes.len(), 1); + let note = &frame.notes[0]; + assert!(cell.origin.y + note.rect.y1 <= cell.clip.y1 + 0.01, "cell footnote must remain within the row"); + assert!(frame.lines.iter().all(|l| l.baseline + l.descent <= note.rect.y0 - d.footnote_options.space_before + 0.01)); +} + #[test] fn footnotes_stack_baseline_to_baseline() { // Single-line footnotes read as one text: consecutive baselines sit one leading (plus From 50b5936b78cbcdfb56bc12968f353659d2d12d51 Mon Sep 17 00:00:00 2001 From: Henrique Cabral Date: Wed, 28 Oct 2026 21:07:42 -0400 Subject: [PATCH 35/48] IDML export: write the document's default StoryPreference InDesign always writes a document-level StoryPreference in Resources/Preferences.xml; DesignCraft only wrote one per story. IDML readers that take the spread reading order from the document's preference, such as Okapi's IDML filter, failed on DesignCraft's files without it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/idml/src/export/fonts_prefs.rs | 10 ++++++++++ crates/idml/src/tests.rs | 8 ++++++++ 3 files changed, 19 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..7070bb62a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/idml/src/export/fonts_prefs.rs b/crates/idml/src/export/fonts_prefs.rs index fac86af33..2e8daa565 100644 --- a/crates/idml/src/export/fonts_prefs.rs +++ b/crates/idml/src/export/fonts_prefs.rs @@ -169,6 +169,16 @@ impl<'a> Ex<'a> { .attr("SubscriptSize", num(a.subscript_size)) .attr("SubscriptPosition", num(a.subscript_position)), ); + // InDesign always writes the document's default story preference; readers such as Okapi's IDML + // filter take the spread reading order from it and fail without it. + root.push( + El::new("StoryPreference") + .attr("OpticalMarginAlignment", "false") + .attr("OpticalMarginSize", "12") + .attr("FrameType", "TextFrameType") + .attr("StoryOrientation", "Horizontal") + .attr("StoryDirection", "LeftToRightDirection"), + ); document(&root) } } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 0d078c63c..b5b5a1cd8 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -554,6 +554,14 @@ fn basic_paragraph_is_normal_paragraph_style() { assert_eq!(back.styles.paragraph.iter().filter(|p| p.name == st::BASIC_PARAGRAPH).count(), 1); } +/// Like InDesign, the export carries the document's default story preference, which IDML readers expect. +#[test] +fn export_writes_the_document_story_preference() { + let d = import_idml_with(&fixture(), &|_| None).unwrap(); + let prefs = zip_text(&export_idml(&d), "Resources/Preferences.xml"); + assert!(prefs.contains(r#""#), "{prefs}"); +} + /// Page items, frames, tables and preferences an IDML file leaves unset are InDesign's defaults /// for a new document. #[test] From 18f2dc2bdea4e8d9d028a4c267352240240d8a44 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 29 Oct 2026 16:43:49 +0500 Subject: [PATCH 36/48] A spent cell budget stops reading tables, rather than keeping one each Keeping a 1 x 1 table for every further `
` was unbounded in the one direction the budget exists to close. `
` is fifteen bytes, so the 512 MiB a part may decompress to holds some 36 million of them, and a `Cell` is 1,360 bytes: tens of gigabytes, with every table inside both caps. The test I wrote said as much without my noticing -- its invariant was `reserved <= MAX + tables`, and `tables` had no bound. `budgeted_size` now returns `None` once the budget is spent and the table is not read at all, so the cells one import reserves are at most the budget, exactly. The invariant is now `reserved <= MAX_IMPORT_TABLE_CELLS`, with no term that grows with the file. The warnings needed the same treatment, for the same reason: a file of 400,000 tables each claiming one row and two columns got a line about every one of them. At most 20 are named, then one line saying the rest were the same, and the file is told once that its tables ran out of cells between them. --- crates/idml/src/import.rs | 7 +++ crates/idml/src/import/story.rs | 7 ++- crates/idml/src/import/table.rs | 104 +++++++++++++++++++++----------- 3 files changed, 81 insertions(+), 37 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index c8e0512b5..ae7364647 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -142,6 +142,11 @@ struct Importer<'r> { /// Cells every table in this import has reserved. One table is capped, but a file chooses /// how many tables it has, so the cap has to be document-wide as well (noted in review). table_cells: usize, + /// Tables whose size the file claimed and did not get, warned about so far. A file chooses + /// how many tables it has, so a line each is its own flood (noted in review). + table_warnings: usize, + /// Whether the file has already been told its tables ran out of cells between them. + table_budget_spent: bool, settings: DocSettings, default_margins: Option<(Margins, Columns)>, swatches: Vec, @@ -229,6 +234,8 @@ impl<'r> Importer<'r> { previews, next_id: 0, table_cells: 0, + table_warnings: 0, + table_budget_spent: false, settings: DocSettings::default(), default_margins: None, swatches: specials, diff --git a/crates/idml/src/import/story.rs b/crates/idml/src/import/story.rs index 694a2dbbd..72cde49b1 100644 --- a/crates/idml/src/import/story.rs +++ b/crates/idml/src/import/story.rs @@ -168,8 +168,11 @@ impl<'r> Importer<'r> { b.fresh = true; } "Table" => { - let t = self.table(c); - b.push_table(t, pf, cf); + // `None` once the file-wide cell budget is spent: the table is not read + // at all rather than kept as a 1 x 1 (noted in review). + if let Some(t) = self.table(c) { + b.push_table(t, pf, cf); + } } "HyperlinkTextDestination" | "ParagraphDestination" => { b.anchors.push((c.get("Self").unwrap_or("").to_string(), c.get("Name").unwrap_or("Anchor").to_string())); diff --git a/crates/idml/src/import/table.rs b/crates/idml/src/import/table.rs index 3b1051b66..91ac287f9 100644 --- a/crates/idml/src/import/table.rs +++ b/crates/idml/src/import/table.rs @@ -7,22 +7,31 @@ use super::*; /// importer collects at most. const MAX_IMPORT_TABLE_CELLS: usize = 400_000; +/// Clamp warnings one import writes before it says the rest were the same. +const MAX_TABLE_WARNINGS: usize = 20; + /// How large a table of `rows` x `cols` may be read when `reserved` cells have already been -/// taken, and whether the file-wide budget is what cut it down. +/// taken, and whether the file-wide budget is what cut it down -- or `None` when the budget is +/// spent and the table is not read at all. /// /// The per-table cap leaves a file free to hold many capped tables, so each is also clamped -/// against what is left. One row and one column are always kept: the table is then still in the -/// document, still says where its text went, and a file pays one cell for a table it really -/// does contain -- which its own size already bounds. -fn budgeted_size(rows: usize, cols: usize, reserved: usize) -> (usize, usize, bool) { +/// against what is left. While there is budget, one row and one column are always kept: the table +/// is then still in the document, still says where its text went, and a file pays one cell for a +/// table it really does contain. +/// +/// Past that there is nothing left to pay with. Keeping a 1 x 1 table for every further `` +/// was unbounded in the one direction the budget was meant to close: `
` is fifteen +/// bytes, so the 512 MiB a part may decompress to holds some 36 million of them, and a `Cell` +/// each is tens of gigabytes with every table inside both caps (noted in review). +fn budgeted_size(rows: usize, cols: usize, reserved: usize) -> Option<(usize, usize, bool)> { let (mut nr, mut nc) = designcraft_doc::table::clamped_size(rows, cols); - let left = MAX_IMPORT_TABLE_CELLS.saturating_sub(reserved); + let left = MAX_IMPORT_TABLE_CELLS.checked_sub(reserved).filter(|left| *left > 0)?; let over_budget = nr.saturating_mul(nc) > left; if over_budget { - nc = nc.min(left.max(1)); + nc = nc.min(left); nr = nr.min((left / nc).max(1)); } - (nr, nc, over_budget) + Some((nr, nc, over_budget)) } /// Preserve the legacy uniform field only when all four sides explicitly specify every @@ -45,8 +54,7 @@ pub(super) fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) impl<'r> Importer<'r> { /// ``: rows, columns and cells (cell `Name` is `column:row`). - pub(super) fn table(&mut self, e: &El) -> Table { - let id = self.alloc(); + pub(super) fn table(&mut self, e: &El) -> Option
{ let rows: Vec<&El> = e.find_all("Row").collect(); let cols: Vec<&El> = e.find_all("Column").collect(); // A file's row and column counts are its claim, and a table of them is allocated whole: a @@ -57,12 +65,31 @@ impl<'r> Importer<'r> { // // Reserved before `Table::new`, so a cell's own story cannot spend the budget this table // is counting on. - let (nr, nc, over_budget) = budgeted_size(rows.len().max(1), cols.len().max(1), self.table_cells); + let Some((nr, nc, over_budget)) = budgeted_size(rows.len().max(1), cols.len().max(1), self.table_cells) else { + // Said once: the file that reaches this is the one with millions of tables in it. + if !self.table_budget_spent { + self.table_budget_spent = true; + self.warnings.push(format!( + "the tables in this file claim more than {MAX_IMPORT_TABLE_CELLS} cells between them: the rest of them were not read" + )); + } + return None; + }; self.table_cells = self.table_cells.saturating_add(nr.saturating_mul(nc)); if nr < rows.len() || nc < cols.len() { let why = if over_budget { "more than the tables in one file can hold between them" } else { "more than a table can hold" }; - self.warnings.push(format!("a table of {} rows and {} columns was read as {nr} x {nc}: {why}", rows.len(), cols.len())); + self.table_warnings += 1; + match self.table_warnings.cmp(&MAX_TABLE_WARNINGS) { + std::cmp::Ordering::Less => { + self.warnings.push(format!("a table of {} rows and {} columns was read as {nr} x {nc}: {why}", rows.len(), cols.len())) + } + std::cmp::Ordering::Equal => { + self.warnings.push(format!("more than {MAX_TABLE_WARNINGS} tables were read smaller than this file claims")) + } + std::cmp::Ordering::Greater => {} + } } + let id = self.alloc(); let rows = &rows[..nr.min(rows.len())]; let cols = &cols[..nc.min(cols.len())]; let mut t = Table::new(id, nr, nc, 0, 0, 0.0); @@ -151,7 +178,7 @@ impl<'r> Importer<'r> { for rg in regions { let _ = t.merge(rg); } - t + Some(t) } /// A cell's local fill, insets, alignment, rotation and edge strokes. @@ -207,45 +234,52 @@ mod budget_tests { fn the_tables_in_one_file_are_capped_between_them() { let mut reserved = 0; let mut sizes = Vec::new(); - for _ in 0..3 { - let (nr, nc, over) = budgeted_size(500, 1000, reserved); - reserved += nr * nc; - sizes.push((nr, nc, over)); + for _ in 0..4 { + let size = budgeted_size(500, 1000, reserved); + if let Some((nr, nc, _)) = size { + reserved += nr * nc; + } + sizes.push(size); } - assert_eq!(sizes[0], (250, 1000, false), "the first table is held by the per-table cap alone"); - assert_eq!(sizes[1], (150, 1000, true), "the second gets what is left of the file's budget"); - assert_eq!(sizes[2], (1, 1, true), "and the third is kept, so its text still has somewhere to be"); - assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS + 1, "400,000 cells and the one the last table keeps"); + assert_eq!(sizes[0], Some((250, 1000, false)), "the first table is held by the per-table cap alone"); + assert_eq!(sizes[1], Some((150, 1000, true)), "the second gets what is left of the file's budget"); + assert_eq!(sizes[2], None, "and the third is not read: there is nothing left to pay for it"); + assert_eq!(sizes[3], None, "nor any after it"); + assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS, "the budget is spent exactly, not overspent"); } - /// The budget holds for any sequence of claimed sizes, and a table is never read as nothing: - /// a 0 x n table has no cells to hold the text the file puts in it. + /// The budget is a hard limit, not a floor: keeping one cell for every further table was + /// unbounded in the one direction the budget exists to close, since a `
` is + /// fifteen bytes and a part may decompress to 512 MiB (noted in review). #[test] - fn a_table_is_never_read_as_empty_and_the_budget_always_holds() { + fn a_spent_budget_stops_reading_tables_altogether() { let claims = [(0, 0), (1, 1), (7, 3), (usize::MAX, usize::MAX), (1, usize::MAX), (usize::MAX, 1), (10_000, 1_000), (2, 0)]; let mut reserved = 0usize; - let mut tables = 0usize; + let mut read = 0usize; + let mut skipped = 0usize; // Every claim, several times over, in an order that exhausts the budget part way through. for _ in 0..4 { for (rows, cols) in claims { - let (nr, nc, _) = budgeted_size(rows.max(1), cols.max(1), reserved); - assert!(nr >= 1 && nc >= 1, "{rows} x {cols} was read as {nr} x {nc}"); - reserved += nr * nc; - tables += 1; - assert!( - reserved <= MAX_IMPORT_TABLE_CELLS + tables, - "{tables} tables reserved {reserved} cells, past the budget by more than the one cell each keeps" - ); + match budgeted_size(rows.max(1), cols.max(1), reserved) { + Some((nr, nc, _)) => { + assert!(nr >= 1 && nc >= 1, "{rows} x {cols} was read as {nr} x {nc}: a table has nowhere to put its text"); + reserved += nr * nc; + read += 1; + } + None => skipped += 1, + } + assert!(reserved <= MAX_IMPORT_TABLE_CELLS, "{read} tables reserved {reserved} cells, past the budget"); } } - assert!(reserved >= MAX_IMPORT_TABLE_CELLS, "this sequence does reach the budget: {reserved}"); + assert_eq!(reserved, MAX_IMPORT_TABLE_CELLS, "this sequence does reach the budget"); + assert!(skipped > 0, "and does go on asking after it: {skipped} tables were not read"); } /// Below the budget nothing is clamped by it, so an ordinary file is read exactly as before. #[test] fn an_ordinary_file_is_not_touched_by_the_budget() { for (rows, cols) in [(1, 1), (2, 3), (50, 20), (250, 1000)] { - assert_eq!(budgeted_size(rows, cols, 0), (rows, cols, false), "{rows} x {cols}"); + assert_eq!(budgeted_size(rows, cols, 0), Some((rows, cols, false)), "{rows} x {cols}"); } } } From e9d7a199943d8926370f1f939de6e0109a35a92a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:58:54 +0000 Subject: [PATCH 37/48] Keep leading and trailing spaces in Tagged Text style names Found while testing crates/textimport/src/tagged.rs:~190-205 (the ParaStyle and CharStyle branches of import: `unescape_value(value.trim())`) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/tagged.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index 74806ae4c..28929c985 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -184,7 +184,7 @@ pub fn import(bytes: &[u8]) -> Result { } "ParaStyle" => { started = true; - let v = unescape_value(value.trim()); + let v = unescape_value(value); if !v.is_empty() && !para_styles.contains(&v) { para_styles.push(v.clone()); } @@ -193,7 +193,7 @@ pub fn import(bytes: &[u8]) -> Result { } } "CharStyle" => { - let v = unescape_value(value.trim()); + let v = unescape_value(value); fmt.style = if v.is_empty() { NO_CHAR_STYLE.into() } else { v.clone() }; if !v.is_empty() && !char_styles.contains(&v) { char_styles.push(v); @@ -205,7 +205,7 @@ pub fn import(bytes: &[u8]) -> Result { // keeps its style's size (#1593). "cSize" => fmt.over.size = value.trim().parse().ok().and_then(designcraft_doc::attrs::imported_type_size), "DefineParaStyle" | "DefineCharStyle" => { - let nm = value.split('=').next().unwrap_or("").trim().to_string(); + let nm = value.split('=').next().unwrap_or("").to_string(); let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles }; if !nm.is_empty() && !list.contains(&nm) { list.push(nm); @@ -278,6 +278,28 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + /// A style name is read back exactly as the exporter wrote it, spaces included. + #[test] + fn style_names_keep_leading_and_trailing_spaces() { + let i = import("\r\n\r\nHello".as_bytes()).unwrap(); + assert_eq!(i.story.paras[0].style, "Body "); + assert_eq!(i.story.char_format_at(2).style, " Em "); + assert_eq!(i.para_styles[0].name, "Body "); + assert_eq!(i.char_styles[0].name, " Em "); + } + + /// A style defined with spaces in its name keeps them, so a paragraph that uses the name matches. + #[test] + fn defined_style_names_keep_leading_and_trailing_spaces() { + let src = + "\r\n>>Hello"; + let i = import(src.as_bytes()).unwrap(); + assert_eq!(i.para_styles.len(), 1); + assert_eq!(i.para_styles[0].name, "Body "); + assert_eq!(i.char_styles.len(), 1); + assert_eq!(i.char_styles[0].name, " Em "); + } + /// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a /// negative, and tagged text took all three and set them on the text (#1593). They are now /// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size. From 6c6efe2f7aff534c8eb5d20fdfb8ec2586a545b1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:43:45 +0000 Subject: [PATCH 38/48] Fix "-0" in Name with Color Value for slightly negative RGB Found while testing crates/color/src/swatch.rs:94-101 (value_name) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/swatch.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index e47bf4eef..d27a76428 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -96,7 +96,9 @@ pub fn value_name(c: Color) -> String { Color::Cmyk { c, m, y, k } => cmyk_name(c, m, y, k), other => { let [r, g, b] = other.to_rgb(); - format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()) + // `+ 0.0` turns -0.0 into 0.0 so it prints as "0", not "-0". + let p = |v: f32| (v * 255.0).round() + 0.0; + format!("R={} G={} B={}", p(r), p(g), p(b)) } } } @@ -172,6 +174,11 @@ pub fn default_swatches() -> Vec { mod tests { use super::*; + #[test] + fn value_name_has_no_negative_zero() { + assert_eq!(value_name(Color::rgb(-0.001, 0.5, 1.0)), "R=0 G=128 B=255"); + } + #[test] fn defaults_start_with_specials() { let s = default_swatches(); From 9fdaa815849ed4800b1589f86ebc6f35042619d0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 18:48:35 -0400 Subject: [PATCH 39/48] packaging: Flatpak finds and prints to CUPS printers (#2012) * packaging: Flatpak finds and prints to CUPS printers The Print dialog listed no printers in the Flatpak: the runtime has libcups but not lpstat/lp, the sandbox couldn't reach the CUPS socket, and driverless network printers (lpstat -e) need Avahi. Bundle lpstat/lp/lpoptions from CUPS 2.4.20 (Apache-2.0, statically linked, licence installed) and grant --socket=cups and --system-talk-name=org.freedesktop.Avahi in both manifests. * packaging: Flatpak reads the user's CUPS settings (~/.cups, read-only) Review (CodeRabbit): without it the sandbox's lp/lpstat ignore the user's default printer and saved options (lpoptions) and a print server named in their client.conf. * packaging: Flatpak CUPS tools reach the host socket when client.conf names a custom path Review: Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock whatever path ~/.cups/client.conf or CUPS_SERVER names, so lp/lpstat/lpoptions now run through a wrapper that points a socket path missing in the sandbox there. Server names (remote servers) and sockets that exist are left alone. Tested in the sandbox: CUPS_SERVER=/run/custom/cups.sock lists the host's printers through the wrapper and none without it; a host name is passed through unchanged. * packaging: redirect a custom CUPS socket only when it is the one Flatpak mounted Review (CodeRabbit): Flatpak picks the socket it mounts at /var/run/cups/cups.sock with stricter rules than CUPS (flatpak-run-cups.c: a socket CUPS_SERVER, else the first line spelled exactly "ServerName /path" in ~/.cups/client.conf, else the default), while CUPS reads the keyword in any case and takes the last ServerName. The wrapper now redirects only when CUPS' socket is the one Flatpak chose, so a job can't reach a different server; otherwise CUPS fails on the missing path. * packaging: never redirect a CUPS_SERVER the sandbox was given Review (CodeRabbit): Flatpak picks the socket it mounts from the host's CUPS_SERVER, so one set by flatpak run --env or an override may not be what it mounted. The wrapper now leaves any CUPS_SERVER alone and redirects only a client.conf socket that matches Flatpak's choice; otherwise CUPS fails on the missing path instead of reaching a different server. * packaging: point CUPS at the mounted socket only when it is the one Flatpak chose, else fail Review (CodeRabbit): with a custom socket listed first and the default last in ~/.cups/client.conf, Flatpak mounts the custom one at /var/run/cups/cups.sock while CUPS picks the last line, finds that path, and talks to the custom server. The wrapper now compares CUPS' socket with Flatpak's choice even when the path exists: equal, CUPS is pointed at the mount; different, the tool fails with an error instead of reaching another server. --- .../ai.storyteller.designcraft.bundle.yml | 52 +++++++++++++++++++ .../flatpak/ai.storyteller.designcraft.yml | 52 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml index 755d2e2a3..09ac37d73 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml @@ -27,6 +27,14 @@ finish-args: - --share=network # GPU access for egui's renderer (Vulkan, or GL as a fallback). - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save, Place and Export go through the file-chooser portal (rfd's xdg-portal backend) # and drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Documents and Pictures are granted so files passed by path (the command @@ -35,6 +43,50 @@ finish-args: - --filesystem=xdg-pictures # Home/host filesystem access is deliberately not granted. modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: designcraft buildsystem: simple build-commands: diff --git a/packaging/linux/flatpak/ai.storyteller.designcraft.yml b/packaging/linux/flatpak/ai.storyteller.designcraft.yml index 1dc2ee4ed..bd456579f 100644 --- a/packaging/linux/flatpak/ai.storyteller.designcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.designcraft.yml @@ -28,6 +28,14 @@ finish-args: - --share=network # GPU access for egui's renderer (Vulkan, or GL as a fallback). - --device=dri + # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and + # find driverless network printers through Avahi (`lpstat -e`). Without these the Print + # dialog lists no printers. + - --socket=cups + - --system-talk-name=org.freedesktop.Avahi + # The user's CUPS settings, read-only: their default printer and saved options (lpoptions) + # and a print server named in their client.conf, which lp reaches over the network. + - --filesystem=~/.cups:ro # Open/Save, Place and Export go through the file-chooser portal (rfd's xdg-portal backend) # and drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Documents and Pictures are granted so files passed by path (the command @@ -38,6 +46,50 @@ finish-args: build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: + # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. + # Linked statically against this release's libcups, so a runtime update can't break them; + # the socket path matches the host's, which --socket=cups mounts. + - name: cups-client + buildsystem: simple + build-commands: + - ./configure --prefix=/app --sysconfdir=/etc --localstatedir=/var --with-rundir=/run/cups + --with-domainsocket=/run/cups/cups.sock --disable-shared --with-components=libcups + --with-dnssd=avahi --with-tls=gnutls --disable-pam --disable-dbus + - make -C cups libs + - make -C systemv lp lpoptions lpstat + - install -Dm755 -t /app/libexec/cups systemv/lp systemv/lpoptions systemv/lpstat + - for tool in lp lpoptions lpstat; do install -Dm755 cups-socket.sh "/app/bin/$tool"; done + - install -Dm644 -t /app/share/licenses/cups LICENSE NOTICE + sources: + - type: archive + url: https://github.com/OpenPrinting/cups/releases/download/v2.4.20/cups-2.4.20-source.tar.gz + sha256: ab4d9cd7f3e58060091d2b24972223d6401675f11c49b65abf4f6ef31dea22ff + # Runs each tool. Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock, choosing + # it as flatpak-run-cups.c does from the host's CUPS_SERVER, else the first line spelled + # exactly `ServerName /path` in ~/.cups/client.conf, else the default; it mounts nothing + # when that socket is missing. CUPS reads the keyword in any case and takes the last + # ServerName. When CUPS' socket from client.conf is the one Flatpak chose, it is pointed at + # the mount; when it isn't, the tool fails rather than reach a different server. A + # CUPS_SERVER is left alone: one set by `flatpak run --env` may not be what was mounted. + - type: script + dest-filename: cups-socket.sh + commands: + - 'conf="$HOME/.cups/client.conf"' + - 'if [ -z "${CUPS_SERVER:-}" ] && [ -r "$conf" ]; then' + - ' server=$(sed -n ''s/^[[:space:]]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]\{1,\}\([^[:space:]]*\).*/\1/p'' "$conf" | tail -n 1)' + - ' case "$server" in' + - ' /*)' + - ' mounted=$(sed -n ''s/^[[:space:]]*ServerName \(.*\)$/\1/p'' "$conf" | sed ''s/^[[:space:]]*//'' | grep -m 1 ''^/[^:]*$'')' + - ' if [ "$mounted" = "$server" ]; then' + - ' export CUPS_SERVER=/var/run/cups/cups.sock' + - ' else' + - ' echo "${0##*/}: ~/.cups/client.conf names the CUPS socket $server, but Flatpak gave this app ${mounted:-a different one}; give each socket one ServerName line, spelled ServerName" >&2' + - ' exit 1' + - ' fi' + - ' ;;' + - ' esac' + - 'fi' + - 'exec "/app/libexec/cups/${0##*/}" "$@"' - name: designcraft buildsystem: simple build-options: From d0fce7e9a3cc93340a3be02cef9e3681848b7c51 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:38:46 -0400 Subject: [PATCH 40/48] Declare Spanish and Simplified Chinese in CFBundleLocalizations Both are interface languages in LANGUAGES but were missing from the list. Co-Authored-By: Claude Opus 5.5 --- packaging/macos/Info.plist.in | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 1fbabdee2..5fdf8a278 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -14,6 +14,7 @@ ar cs de + es fr it ja @@ -22,6 +23,7 @@ pt-BR ru uk + zh-Hans CFBundleDisplayName DesignCraft From 84801932f2897a4b355e634132f58c7d2ae01996 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:39:23 -0400 Subject: [PATCH 41/48] Renumbering duplicate ids stops at the top of the id range instead of overflowing Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 5 ++++- crates/format/src/tests.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 08ba34ca0..fecb2c3b4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1007,7 +1007,10 @@ impl Document { return; } if !seen.insert(old) { - next += 1; + // An id at the top of the range has no successor: leave the duplicate for + // `check()` to refuse rather than overflow. + let Some(n) = next.checked_add(1) else { return }; + next = n; it.id = ItemId(next); } if let Some(t) = it.text_frame() { diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index 702428d14..882f5e6b8 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -822,3 +822,17 @@ fn duplicate_anchored_item_ids_are_renumbered() { assert_eq!(anchored[0].1.shape, Shape::Oval); assert_ne!(anchored[0].1.id, g); } + +#[test] +fn duplicate_ids_at_the_top_of_the_range_do_not_overflow() { + let mut d = Document::new(&NewDocument::default()); + let one = png_asset(&mut d, &[1]); + let two = png_asset(&mut d, &[2]); + place(&mut d, one, 0.0); + place(&mut d, two, 0.0); + set_item_id(&mut d, 0, designcraft_doc::ItemId(u64::MAX)); + set_item_id(&mut d, 1, designcraft_doc::ItemId(u64::MAX)); + d.renumber_duplicate_items(); + assert_eq!(all_item_ids(&d), vec![u64::MAX, u64::MAX]); + assert!(d.check().is_err()); +} From a9256308e32ee8cadd2051226986003ff80fbcb9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:39:35 -0400 Subject: [PATCH 42/48] Keep at least four wrap passes for a story with few anchored objects A single wrapping object moved by keep options or vertical justification had four passes to settle before; one-per-object would have given it two. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/wrap.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 7d38f0ef8..724310b00 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -15,7 +15,8 @@ //! moving. An object's wrap can push the anchors of the objects after it, so a chain of n such //! objects needs n + 1 more compositions: the k-th pass places the (k + 1)-th object where the //! wraps before it leave its anchor, and the last pass wraps around the last object. The story -//! is composed at most that many more times, and never more than [`MAX_PASSES`]. +//! is composed at most that many more times (at least [`MIN_PASSES`]), and never more than +//! [`MAX_PASSES`]. use designcraft_doc::{AnchorPosition, AnchoredObject, Document, Item, OBJECT_MARK, PageSide, SpreadRef, Story, StoryId, WrapMode}; use designcraft_geom::Affine; @@ -24,9 +25,13 @@ use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec}; /// Most extra compositions of a story with its own anchored objects' wrap, however many objects /// it has; a layout that keeps moving its objects (keep options, vertical justification) stops -/// sooner, after one per object and one more. +/// sooner, after one per object and one more (but never fewer than [`MIN_PASSES`]). pub(crate) const MAX_PASSES: usize = 64; +/// Fewest extra compositions allowed before giving up on a layout that keeps moving: a single +/// object that keep options or vertical justification move still gets the room it had before. +const MIN_PASSES: usize = 4; + /// How far an obstacle may move between passes and still count as settled (points). const SETTLED: f64 = 0.01; @@ -205,7 +210,7 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, } let own = st.objects.iter().filter(|o| wraps(o) && !matches!(o.position, AnchorPosition::Inline { .. })).count(); let mut prev = vec![Vec::new(); specs.len()]; - for _ in 0..(own + 1).min(MAX_PASSES) { + for _ in 0..(own + 1).max(MIN_PASSES).min(MAX_PASSES) { let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true)); if settled(&added, &prev) { break; From e6e478bccba5eb3fc46a77d16909afaaa4eaa77e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:39:41 -0400 Subject: [PATCH 43/48] Leave the Windows print test to #1840 The expectation change is unrelated to hyphenation and lands with #1840. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/print.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index dfa0a410d..e71c230de 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -86,15 +86,7 @@ mod tests { } else { assert_eq!( command, - [ - "xps-print", - "printer=Office", - "copies=2", - "collate=true", - "duplex=(queue default)", - "scaling=shrink-to-fit", - "color=(driver default)" - ] + ["xps-print", "printer=Office", "copies=2", "collate=true", "duplex=(queue default)", "scaling=shrink-to-fit", "color=color"] ); } assert!(r["bytes"].as_u64().unwrap() > 500); From 794b7a29de1c754f73ec415f4187061e856cf1a7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:39:56 -0400 Subject: [PATCH 44/48] Hold the guide-drag cursor only while the primary button is down Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 19bcb87e3..bf8a88563 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -744,8 +744,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { None => c, }; // A ruler guide being dragged keeps the cursor across it until it is dropped, over the - // canvas too (the tool's cursor took over as soon as the pointer left the ruler). - let c = match ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))).map(|g| g.orientation) { + // canvas too (the tool's cursor took over as soon as the pointer left the ruler). Only + // while the button is down: a drag state left behind by a skipped release doesn't stick. + let dragging = ui.input(|i| i.pointer.primary_down()); + let c = match ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))).filter(|_| dragging).map(|g| g.orientation) { Some(designcraft_doc::Orientation::Horizontal) => Cursor::ResizeV, Some(designcraft_doc::Orientation::Vertical) => Cursor::ResizeH, None => c, From 44b07403b1502cd676108f75f81b895dda4a2348 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:40:04 -0400 Subject: [PATCH 45/48] Draw a checkbox's border at the item's stroke weight Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/forms.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index bf0d955fc..73165dc7a 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -179,7 +179,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - FieldKind::CheckBox => { // Appearances: an empty box, and a box with a check mark. let border = - if *bw > 0.0 { format!("0.45 G 1 w 0.5 0.5 {:.2} {:.2} re S", (w - 1.0).max(0.0), (h - 1.0).max(0.0)) } else { String::new() }; + if *bw > 0.0 { format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0) } else { String::new() }; let check = format!( "{border} 0 g {:.2} w 1 J 1 j {:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l S", (w.min(h) * 0.12).max(0.8), From 680dfdc964b3dd86858326fc44c1f963f3f9fc4c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:41:41 -0400 Subject: [PATCH 46/48] Format the checkbox border Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/forms.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 73165dc7a..90234859d 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -178,8 +178,11 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - } FieldKind::CheckBox => { // Appearances: an empty box, and a box with a check mark. - let border = - if *bw > 0.0 { format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0) } else { String::new() }; + let border = if *bw > 0.0 { + format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0) + } else { + String::new() + }; let check = format!( "{border} 0 g {:.2} w 1 J 1 j {:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l S", (w.min(h) * 0.12).max(0.8), From cc47975960edb8ff3993b35f160dcbf9664a06c5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:43:16 -0400 Subject: [PATCH 47/48] Use clamp for the wrap pass bounds Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/wrap.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 724310b00..f28bba424 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -31,6 +31,8 @@ pub(crate) const MAX_PASSES: usize = 64; /// Fewest extra compositions allowed before giving up on a layout that keeps moving: a single /// object that keep options or vertical justification move still gets the room it had before. const MIN_PASSES: usize = 4; +// `clamp` needs min <= max. +const _: () = assert!(MIN_PASSES <= MAX_PASSES); /// How far an obstacle may move between passes and still count as settled (points). const SETTLED: f64 = 0.01; @@ -210,7 +212,7 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, } let own = st.objects.iter().filter(|o| wraps(o) && !matches!(o.position, AnchorPosition::Inline { .. })).count(); let mut prev = vec![Vec::new(); specs.len()]; - for _ in 0..(own + 1).max(MIN_PASSES).min(MAX_PASSES) { + for _ in 0..(own + 1).clamp(MIN_PASSES, MAX_PASSES) { let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true)); if settled(&added, &prev) { break; From eace4735dac32df6785e82718991f391bdc5ac97 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:43:49 -0400 Subject: [PATCH 48/48] Draw the checkbox border in its own function Keeps add_fields under the cognitive complexity limit. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/forms.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index 90234859d..e36478971 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -79,6 +79,16 @@ fn pdf_string(s: &str) -> String { o } +/// A checkbox appearance's border: the item's stroke weight, inset by half of it so it stays +/// inside the box; nothing for an unstroked item. +fn checkbox_border(bw: f64, w: f64, h: f64) -> String { + if bw > 0.0 { + format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0) + } else { + String::new() + } +} + /// Add `fields` and `media` to `pdf` (page indices in output order). `None` when the file's /// structure isn't the simple kind this understands. pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -> Option> { @@ -178,11 +188,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) - } FieldKind::CheckBox => { // Appearances: an empty box, and a box with a check mark. - let border = if *bw > 0.0 { - format!("0.45 G {bw:.2} w {i:.2} {i:.2} {:.2} {:.2} re S", (w - bw).max(0.0), (h - bw).max(0.0), i = bw / 2.0) - } else { - String::new() - }; + let border = checkbox_border(*bw, w, h); let check = format!( "{border} 0 g {:.2} w 1 J 1 j {:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l S", (w.min(h) * 0.12).max(0.8),