diff --git a/Cargo.lock b/Cargo.lock index 2cef88cfb..5505fc5f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.19.0" +version = "0.20.0" dependencies = [ "arboard", "craft-update", @@ -1331,7 +1331,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.19.0" +version = "0.20.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1348,7 +1348,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.19.0" +version = "0.20.0" dependencies = [ "craft-cms", "kurbo", @@ -1360,7 +1360,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.19.0" +version = "0.20.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1380,7 +1380,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.19.0" +version = "0.20.0" dependencies = [ "chrono", "designcraft-color", @@ -1393,7 +1393,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.19.0" +version = "0.20.0" dependencies = [ "caseless", "craft-atomic", @@ -1433,7 +1433,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.19.0" +version = "0.20.0" dependencies = [ "chrono", "designcraft-color", @@ -1443,7 +1443,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.19.0" +version = "0.20.0" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1456,7 +1456,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.19.0" +version = "0.20.0" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1468,7 +1468,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.19.0" +version = "0.20.0" dependencies = [ "flo_curves", "kurbo", @@ -1481,7 +1481,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.19.0" +version = "0.20.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.19.0" +version = "0.20.0" dependencies = [ "craft-cms", "designcraft-color", @@ -1514,7 +1514,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.19.0" +version = "0.20.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1527,7 +1527,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.19.0" +version = "0.20.0" dependencies = [ "chrono", "craft-cms", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.19.0" +version = "0.20.0" dependencies = [ "chrono", "designcraft-color", @@ -1571,7 +1571,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.19.0" +version = "0.20.0" dependencies = [ "craft-pdf", "designcraft-color", @@ -1590,7 +1590,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.19.0" +version = "0.20.0" dependencies = [ "designcraft-doc", "quick-xml 0.41.0", @@ -1599,7 +1599,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.19.0" +version = "0.20.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1611,7 +1611,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.19.0" +version = "0.20.0" dependencies = [ "craft-dialogs", "craft-keymap", @@ -1645,7 +1645,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.19.0" +version = "0.20.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -7679,7 +7679,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.19.0" +version = "0.20.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 5a4143f12..ee27e5fbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.19.0" +version = "0.20.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/ROADMAP.md b/ROADMAP.md index 81e87eea3..006396b1d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -352,15 +352,14 @@ A string without a translation shows in English and fails no check; `cargo xtask | Hindi | hi | none | 0% | | Arabic | ar | partial (RTL UI and composition) | 85% | | French | fr | partial | 83% | -| Portuguese (Brazil) | pt-br | partial | 61% | +| Portuguese (Brazil) | pt-br | partial | ~100% | | Indonesian | id | none | 0% | | Japanese | ja | partial | 95% | | German | de | partial | 83% | | Korean | ko | none | 0% | | Vietnamese | vi | none | 0% | -Also shipped: Czech (99%), Italian (85%), Ukrainian (83%), Polish (99%). -Also shipped: Italian (85%), Ukrainian (83%), Georgian (100%), Swedish (99%). +Also shipped (2026-11-04 `i18n-report`, of 2,939): Czech (69%), Italian (68%), Ukrainian (65%), Polish (64%), Georgian (98%), Swedish (98%), Hungarian (65%), Russian (63%). ## Upcoming @@ -402,6 +401,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-11-06** · Preflight warns about white or [Paper] set to overprint (#2401): a fill, stroke or stroke gap, or ruby and kenten, in [Paper] or a colour that lays down no ink (C=0 M=0 Y=0 K=0, a 0% tint; a spot ink, such as a white ink or a varnish, prints whatever colour stands in for it), is reported as `whiteOverprint`, since it disappears on press. Text itself has no overprint, so white text in a frame set to Overprint Fill still knocks out and prints, and is not reported. Preflight no longer says PDF export drops ruby and kenten overprint, which it writes. No readiness percentage change. +- **2026-11-04** · Interface Language › Português (Brasil) translates the whole interface: 996 strings the catalog lacked (tool names, window and guide commands, Keyboard Shortcuts sets, panel docking, Edit › Menus paths, the Japanese typesetting dialogs, special characters, Load Styles, Data Merge, Preflight, font replacement, the Package summary, OpenType features, page presets, About) and File › New's unit names, and a path is "demarcador" throughout. `cargo xtask i18n-report` counts 2,936 of 2,939 strings for pt-BR (1,933 before); only the Control panel's `IT`, `VA` and `Y:` captions stay English, as in every language. No readiness percentage change. - **2026-11-03** · `type.char` says when the font it applies isn't available (#2189): it returns `{"warnings": ["Missing font: Courier Prime (using Source Sans 3)"]}`, or `Missing font style: …` for a style the family doesn't have, in preflight's words. The font is still applied, as before, so Find Font and preflight list it. A script that asked for a font the machine doesn't have exported in the substitute with no word about it. No readiness percentage change. - **2026-11-02** · Packaged PDFs report their export warnings in the package result and Instructions.txt, and soft drop shadows and feathers in them are tested. Frame and text-range hyperlinks keep their source bounds when the frame is rasterized, including inside groups. No readiness percentage change. - **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing targets only the selected parent page and preserves opposite-page margins and document overrides; document commands retain their first-page default before the canvas is initialized. No readiness percentage change. @@ -413,6 +413,7 @@ Newest first. - **2026-10-30** · macOS: a file dragged in from the Finder fills the frame it is dropped on. winit sends no pointer events during such a drag, so the drop asks the system where the pointer is (`Services::pointer`, from `NSEvent.mouseLocation`), at any UI scale. No readiness percentage change. - **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515). - **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255). +- **2026-10-30** · Spreads show a line at each edge where two of their pages meet, on the canvas only (it isn't printed or exported), so the page boundaries inside a spread are visible while laying out across them, in the spread edge's colour. Right-to-left spreads and pages of different sizes put it on the edge the pages share; pages that don't touch, as an imported spread can keep them, get none (#1838). No readiness percentage change. - **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change. - **2026-10-28** · A placed image whose pixels would decode past 1 GiB of RGBA (256 Mpx) is refused from its header, before decoding (#1586). A 288 KB 23,000 px gray PNG used to hang the first draw for 9 s and take the app to 4.5 GB; it now draws as an empty frame, and PDF export skips it with a warning. A 300 ppi A0 scan still fits. No readiness percentage change. - **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change. @@ -485,6 +486,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-20** · MCP: every tool states all four behaviour hints (read-only, destructive, idempotent, open-world), input schemas are closed, and a misspelt key inside a `batch` step, a `pointer` event or `mods` is refused (`-32602`) like a top-level one instead of being ignored; a JSON script step with a key other than `command`/`params` is an error. A panic outside a command is a tool error instead of ending the server. 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-20** · The Links panel lists a document's links at once and takes their statuses from a check of the local and network files on a background worker, showing the last ones found meanwhile. It keeps the size and time of a graphic's file with the document, so an unchanged link needs no read even on the first check after opening (a file with a new time but the same size is compared once and then remembered, so a copied or restored folder stays OK). External changes are polled every two seconds, or as long as the last check took on a slow share; a document edit shows at once, and a selection change (Go To) starts no check. 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-21** · Properties for points selected with Direct Selection, as InDesign's: the title is "Anchor Point(s)", and the panel shows Transform, Appearance, Convert Points (Plain, Corner, Smooth, Symmetrical) and the Delete Point(s) quick action (`path.deletePoints`, one undo step). Found in Zero2Graphic's *How to Page Layout design in Adobe InDesign 2022 CC*. No readiness percentage change. - **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change. diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 61546d162..b387384f9 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -13,6 +13,9 @@ //! - Lookahead, lookbehind, atomic groups, possessive quantifiers, backreferences and `\K` are //! supported. Backtracking is capped ([`BACKTRACK_LIMIT`]), so a pathological pattern stops //! with an error instead of hanging. +//! - Subroutine calls and recursion (`\g`, `\g<1>`, `\g<-1>`) are not part of the dialect +//! and are refused: the regex engine inlines each call while compiling, so a short pattern whose +//! groups call each other more than once expands exponentially. use std::fmt; use std::ops::Range; @@ -30,6 +33,11 @@ pub const BACKTRACK_LIMIT: usize = 200_000; /// on every relayout (#1598). A paragraph cannot usefully carry more styled runs than this, and /// a scan that reaches it stops with what it has. pub const MAX_MATCHES_PER_PARAGRAPH: usize = 4096; +/// Searches [`Grep::find_nonempty`] may run per match it is allowed to return. Empty matches +/// don't count toward that match limit, but each one is still a search (a lookaround can scan the +/// rest of the paragraph): without this a pattern that only ever matches empty searched once per +/// character, quadratic in the paragraph's length. +pub const SEARCHES_PER_MATCH: usize = 4; /// Compiled program size cap (bytes) for the underlying regex engine. const SIZE_LIMIT: usize = 4 << 20; @@ -363,7 +371,13 @@ impl Grep { (false, true) => "(?m)", (false, false) => "", }; - let re = fancy_regex::RegexBuilder::new(&format!("{flags}{pat}")) + let pat = format!("{flags}{pat}"); + // Refused before compiling: the compiler inlines every subroutine call (to a fixed depth), + // so `(a\g<1>\g<1>)` costs time and memory exponential in that depth. + if fancy_regex::Expr::parse_tree(&pat).is_ok_and(|t| t.contains_subroutines) { + return Err(err("GREP: subroutine calls and recursion are not supported")); + } + let re = fancy_regex::RegexBuilder::new(&pat) .backtrack_limit(BACKTRACK_LIMIT) .delegate_size_limit(SIZE_LIMIT) .delegate_dfa_size_limit(SIZE_LIMIT) @@ -396,14 +410,26 @@ impl Grep { } /// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it - /// doesn't count toward a GREP style's cap. - pub fn find_nonempty(&self, text: &str, limit: usize) -> Result>, GrepError> { - self.re - .find_iter(text) - .filter(|m| !m.as_ref().is_ok_and(|m| m.range().is_empty())) - .take(limit) - .map(|m| m.map(|m| m.range()).map_err(run_err)) - .collect() + /// doesn't count toward a GREP style's cap. Empty matches still cost a search each, so the + /// scan stops after `limit` × [`SEARCHES_PER_MATCH`] searches in all. + /// + /// The flag is true when the search budget ran out with text left to scan, so the matches may + /// be incomplete (reaching `limit` is not flagged: the caller sees the full vector). + pub fn find_nonempty(&self, text: &str, limit: usize) -> Result<(Vec>, bool), GrepError> { + let mut it = self.re.find_iter(text); + let mut out = Vec::new(); + for _ in 0..limit.saturating_mul(SEARCHES_PER_MATCH) { + if out.len() >= limit { + return Ok((out, false)); + } + let Some(m) = it.next() else { return Ok((out, false)) }; + let r = m.map_err(run_err)?.range(); + if !r.is_empty() { + out.push(r); + } + } + let more = out.len() < limit && it.next().transpose().map_err(run_err)?.is_some(); + Ok((out, more)) } /// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see @@ -497,6 +523,32 @@ mod tests { assert_eq!(g.replacements("5 kg", &tpl, false).unwrap(), [(0..4, "kg\u{A0}5x\n$$".to_string())]); } + #[test] + fn subroutine_calls_are_refused() { + // Each group calls itself twice: compiling inlines 2^depth copies. + for p in [r"(a\g<1>\g<1>)", r"(?a\g\g)", r"(?a|b\g)", r"(a)(b)\g<-1>", r"(a)\g<1>"] { + let t = std::time::Instant::now(); + let e = Grep::new(p, GrepOptions::default()).unwrap_err(); + assert!(e.0.contains("subroutine"), "{p}: {e}"); + assert!(t.elapsed().as_secs() < 5, "{p}"); + } + // Backreferences and groups are still fine. + assert_eq!(found(r"(a)\1", "xaa"), ["aa"]); + assert_eq!(found(r"(?a)\k", "xaa"), ["aa"]); + } + + #[test] + fn empty_matches_use_up_the_search_budget() { + // A pattern that matches empty at every character: the non-empty match at the end is + // found within the budget and not past it. + let g = grep("a*"); + let near = format!("{}a", "b".repeat(10 * SEARCHES_PER_MATCH - 2)); + let (ms, exhausted) = g.find_nonempty(&near, 10).unwrap(); + assert_eq!((ms.len(), ms[0].clone(), exhausted), (1, near.len() - 1..near.len(), false)); + let far = format!("{}a", "b".repeat(10 * SEARCHES_PER_MATCH + 50)); + assert_eq!(g.find_nonempty(&far, 10).unwrap(), (vec![], true), "the scan stops at the search budget and says so"); + } + #[test] fn catastrophic_backtracking_stops() { // Exponential in the backtracking engine (the backreference forces it): without the limit diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index 6cd3df72f..558bf5bec 100644 --- a/crates/compose/src/overlay.rs +++ b/crates/compose/src/overlay.rs @@ -116,7 +116,10 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], with_pattern(&g.pattern, |slot| { let Some(re) = slot else { return }; match re.find_nonempty(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) { - Ok(ms) => { + Ok((ms, exhausted)) => { + if exhausted { + log::warn!("GREP style {:?} ran out of search budget in one paragraph; the rest may not be styled", g.pattern); + } if ms.len() == crate::grep::MAX_MATCHES_PER_PARAGRAPH { log::warn!("GREP style {:?} matched more than {} times in one paragraph; the rest are not styled", g.pattern, ms.len()); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a47e27f45..f15f1e33d 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -5706,6 +5706,43 @@ fn inline_object_only_line_does_not_reserve_phantom_font_descent() { assert!(compose_story(&d, sid, &ComposeOptions::default()).is_overset()); } +/// A story threaded over frames on pages 1 and 2, with a text frame anchored in the text that +/// flows into the second frame: the anchored frame is on page 2, where it is placed, and its page +/// number reads 2 (not the page of the host story's first frame). +#[test] +fn an_anchored_text_frame_in_a_later_frame_is_on_that_frames_page() { + use designcraft_doc::{AnchorPosition, AnchoredObject, story::FRAME_BREAK}; + let mut d = Document::new(&NewDocument { pages: 2, facing_pages: false, ..Default::default() }); + let lid = d.default_layer(); + let text = format!("One{FRAME_BREAK}Two "); + let (first, host) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 436.0), lid, &text, ParaFormat::default()).unwrap(); + let (second, _) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(36.0, 36.0, 336.0, 436.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(first, second).unwrap(); + assert_eq!(d.story(host).unwrap().frames, vec![first, second]); + // A text frame showing a page number, anchored at the end of the host's text (in its second frame). + let (fid, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(0.0, 0.0, 150.0, 40.0), + lid, + &designcraft_doc::story::PAGE_NUMBER.to_string(), + ParaFormat::default(), + ) + .unwrap(); + let item = d.item(fid).unwrap().clone(); + d.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != fid); + let st = d.story_mut(host).unwrap(); + let n = st.len(); + st.insert_object(n, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: 0.0 })); + d.check().unwrap(); + let placed = placed::placements(&d, sid, &mut |h| Arc::new(compose_story(&d, h, &ComposeOptions::default()))); + let spec = &frame_specs_placed(&d, sid, &placed)[0]; + assert_eq!((spec.page_name.as_deref(), spec.page), (Some("2"), Some(1))); + let cs = Cache::new().get(&d, sid, None); + let g = cs.frames[0].lines[0].glyphs.iter().find(|g| g.visible).unwrap(); + assert_eq!(g.gid, g.face.get().glyph_for('2'), "the page number of page 2"); +} + #[test] fn composite_metrics_and_character_mappings_apply_inside_footnotes() { use designcraft_doc::cjk::{CompositeFont, CompositeFontEntry}; diff --git a/crates/doc/src/anchored.rs b/crates/doc/src/anchored.rs index 74fc8818f..524e2bfc5 100644 --- a/crates/doc/src/anchored.rs +++ b/crates/doc/src/anchored.rs @@ -4,14 +4,26 @@ //! [`AnchoredObject`] per mark in text order (like footnotes). The item's geometry is kept with //! its top-left at the origin; composition places it on the line (inline) or on a line of its //! own above the line it is anchored in (above line). +//! +//! An anchored text frame (also one in an anchored group, a footnote or a table cell) is in its +//! story's thread like a frame on a page: [`Document::fix_anchored_text_frames`] keeps it so +//! after edits that copy or delete text and frames, and [`Document::adopt_anchored`] gives +//! copies brought into a document stories of their own. +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use serde::{Deserialize, Serialize}; +use crate::Document; +use crate::ids::{AssetId, ItemId, StoryId}; use crate::item::Item; use crate::story::Story; +/// How deep anchored text frames (and footnotes and cells) are followed into each other: the +/// nesting [`Document::check`] accepts. +const NESTING: usize = 64; + /// An anchored object's position in the text. pub const OBJECT_MARK: char = '\u{E00E}'; @@ -137,3 +149,673 @@ impl Story { self.objects.get(self.text[..pos].matches(OBJECT_MARK).count()).map(|o| &**o) } } + +/// Visit the items anchored in `st`, its footnotes, endnotes and table cells (and theirs, so +/// deep), in order: a story's objects (a group before its items), then its notes and its cells. +fn items(st: &Story, depth: usize, f: &mut dyn FnMut(&Item)) { + for o in &st.objects { + o.item.walk(&mut |i| f(i)); + } + if depth >= NESTING { + return; + } + for n in st.notes.iter().chain(&st.endnotes) { + items(&n.text, depth + 1, f); + } + for t in st.tables.values() { + for c in &t.cells { + items(&c.text, depth + 1, f); + } + } +} + +/// [`items`], mutably (copy-on-write only where items are). +fn items_mut(st: &mut Story, depth: usize, f: &mut dyn FnMut(&mut Item)) { + for o in &mut st.objects { + walk_mut(&mut Arc::make_mut(o).item, f); + } + if depth >= NESTING { + return; + } + for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + if has_items(&n.text) { + items_mut(&mut Arc::make_mut(n).text, depth + 1, f); + } + } + for t in st.tables.values_mut() { + if t.cells.iter().any(|c| has_items(&c.text)) { + for c in &mut Arc::make_mut(t).cells { + items_mut(&mut c.text, depth + 1, f); + } + } + } +} + +fn walk_mut(it: &mut Item, f: &mut dyn FnMut(&mut Item)) { + f(it); + if let Some(children) = it.children_mut() { + for c in children { + walk_mut(Arc::make_mut(c), f); + } + } +} + +/// Does `st` (or a footnote or cell in it) hold anchored objects? +fn has_items(st: &Story) -> bool { + let mut todo = vec![st]; + while let Some(s) = todo.pop() { + if !s.objects.is_empty() { + return true; + } + todo.extend(s.notes.iter().chain(&s.endnotes).map(|n| &n.text)); + todo.extend(s.tables.values().flat_map(|t| t.cells.iter().map(|c| &c.text))); + } + false +} + +impl Document { + /// Does any story (its footnotes and table cells included) hold anchored objects? + pub fn has_anchored_items(&self) -> bool { + self.stories.values().any(|s| has_items(s)) + } + + /// The stories the anchored text frames in `st` show, and the stories shown by the frames + /// anchored in those: what copied text takes along. + pub fn anchored_stories(&self, st: &Story) -> BTreeMap> { + let mut out = BTreeMap::new(); + let mut todo = Vec::new(); + items(st, 0, &mut |i| todo.extend(i.text_frame().map(|t| (t.story, 1)))); + while let Some((sid, depth)) = todo.pop() { + if depth > NESTING || out.contains_key(&sid) { + continue; + } + let Some(s) = self.stories.get(&sid) else { continue }; + out.insert(sid, s.clone()); + items(s, 0, &mut |i| todo.extend(i.text_frame().map(|t| (t.story, depth + 1)))); + } + out + } + + /// Text copied into this document (from it or another one): give the items anchored in `st` + /// fresh ids, and each anchored text frame among them a copy of its story from `src` (an + /// empty story when `src` hasn't it) threaded to the copied frames that show it, copying the + /// frames anchored in those stories the same way. + pub fn adopt_anchored(&mut self, st: &mut Story, src: &dyn Fn(StoryId) -> Option>) { + self.adopt(&mut |f| items_mut(st, 0, f), src, None, &mut Vec::new(), &mut HashMap::new()); + } + + /// [`Document::adopt_anchored`] for text copied from document `src`: the copies of the + /// stories its anchored text frames show also take the assets they place along from `src` + /// (or reuse the same file here), since `src`'s asset ids may name other files here. + pub fn adopt_anchored_from(&mut self, st: &mut Story, src: &Document) { + self.adopt(&mut |f| items_mut(st, 0, f), &|id| src.stories.get(&id).cloned(), Some(src), &mut Vec::new(), &mut HashMap::new()); + } + + /// `visit` runs a function on every copied item. `path`: the stories being copied (a frame + /// showing one of them again gets an empty story, so copies never nest without end). + /// `assets`: the document the copied stories' assets are in, when it isn't this one. + /// `copies`: the copy made of each source story in this operation. A story shown by frames in + /// several copied stories is copied once and threads all their copies, as the original does, + /// so a graph of shared stories doesn't multiply into exponentially many copies. + fn adopt( + &mut self, + visit: &mut dyn FnMut(&mut dyn FnMut(&mut Item)), + src: &dyn Fn(StoryId) -> Option>, + assets: Option<&Document>, + path: &mut Vec, + copies: &mut HashMap, + ) { + let mut next = self.next_id; + let mut frames = Vec::new(); + visit(&mut |i| { + next = next.saturating_add(1); + i.id = ItemId(next); + frames.extend(i.text_frame().map(|t| (i.id, t.story))); + }); + self.next_id = next; + // The story each copied frame shows: the operation's copy of its story, or an empty one + // where copying would loop or nest too deep. + let mut shows: HashMap = HashMap::new(); + let mut empty: HashMap = HashMap::new(); + for &(fid, old) in &frames { + let looped = path.contains(&old) || path.len() >= NESTING; + if let Some(&id) = if looped { empty.get(&old) } else { copies.get(&old) } { + shows.insert(fid, id); + continue; + } + let id = StoryId(self.alloc()); + shows.insert(fid, id); + let copy = match src(old) { + Some(s) if !looped => { + copies.insert(old, id); + let mut copy = (*s).clone(); + copy.id = id; + copy.frames.clear(); + if let Some(from) = assets { + // The graphics it places, under this document's ids. + let mut map = HashMap::new(); + for a in crate::story_asset_refs(©) { + let here = self.adopt_asset(from, a).unwrap_or_else(|| AssetId(self.alloc())); + if here != a { + map.insert(a, here); + } + } + crate::remap_story_assets(&mut copy, &map); + } + path.push(old); + self.adopt(&mut |f| items_mut(&mut copy, 0, f), src, assets, path, copies); + path.pop(); + copy + } + _ => { + if looped { + empty.insert(old, id); + } else { + copies.insert(old, id); + } + Story::new(id) + } + }; + self.stories.insert(id, Arc::new(copy)); + } + visit(&mut |i| { + if let Some(id) = shows.get(&i.id) + && let Some(t) = i.text_frame_mut() + { + t.story = *id; + } + }); + for (fid, _) in frames { + if let Some(st) = shows.get(&fid).and_then(|id| self.stories.get_mut(id)) { + Arc::make_mut(st).frames.push(fid); + } + } + } + + /// Could the edit from `old` to this document have left anchored items for + /// [`Document::fix_anchored_text_frames`] to repair? Only a story that was added, removed or + /// changed can: one that holds anchored items whose ids or stories changed, or whose thread + /// changed (a frame deleted or copied). Typing, formatting and moving frames don't, so the + /// repair, which walks every story, doesn't run for them. Story by story: unchanged stories + /// share their allocation. + pub fn anchored_items_changed_since(&self, old: &Document) -> bool { + // The anchored items of `st` that the repair looks at: their ids and the stories they show. + let anchored = |st: &Story| { + let mut out = Vec::new(); + items(st, 0, &mut |i| out.push((i.id, i.text_frame().map(|t| t.story)))); + out + }; + for (id, st) in &self.stories { + match old.stories.get(id) { + Some(before) if Arc::ptr_eq(before, st) => {} + Some(before) => { + if before.frames != st.frames || anchored(before) != anchored(st) { + return true; + } + } + None => { + if has_items(st) { + return true; + } + } + } + } + // A removed story: its anchored items went with it, or an anchored frame may show it. + let removed = old.stories.iter().filter(|(id, _)| !self.stories.contains_key(id)); + removed.into_iter().any(|(_, st)| has_items(st) || self.has_anchored_items()) + } + + /// Ids of the items on spreads and parents, and of the items anchored in stories. + fn live_items(&self) -> HashSet { + let mut live = HashSet::new(); + for sp in self.spreads.iter().chain(&self.parents) { + for it in &sp.items { + it.walk(&mut |i| { + live.insert(i.id); + }); + } + } + for st in self.stories.values() { + items(st, 0, &mut |i| { + live.insert(i.id); + }); + } + live + } + + /// Keep anchored items valid ([`Document::check`]) after edits that copy or delete text and + /// frames: an anchored item whose id is taken (text copied in the document) gets a fresh one; + /// threads lose frames that are gone, and a story whose last frame went goes too (unless a + /// frame still shows it); an anchored text frame its story doesn't thread is added to the + /// story when that has no frames left, else gets a copy of it (an empty story when it is + /// missing). Returns how many items, threads and stories changed. + pub fn fix_anchored_text_frames(&mut self) -> usize { + let mut changed = 0; + // Fresh ids for copies: items on spreads keep theirs, then the first in story order. + let mut seen = HashSet::new(); + for sp in self.spreads.iter().chain(&self.parents) { + for it in &sp.items { + it.walk(&mut |i| { + seen.insert(i.id); + }); + } + } + let sids: Vec = self.stories.keys().copied().collect(); + for sid in &sids { + let Some(st) = self.stories.get(sid) else { continue }; + let mut ids = Vec::new(); + items(st, 0, &mut |i| ids.push(i.id)); + let mut own = HashSet::new(); + if !ids.iter().any(|id| seen.contains(id) || !own.insert(*id)) { + seen.extend(ids); + continue; + } + let mut next = self.next_id; + let Some(st) = self.stories.get_mut(sid) else { continue }; + let st = Arc::make_mut(st); + items_mut(st, 0, &mut |i| { + if !seen.insert(i.id) { + next = next.saturating_add(1); + i.id = ItemId(next); + seen.insert(i.id); + changed += 1; + } + }); + st.rev = st.rev.wrapping_add(1); + self.next_id = next; + } + // Frames that are gone (deleted with their text or their host's frame) leave their + // threads; a story left without frames goes with them, and so on into the stories its + // anchored frames showed. + let mut live = seen; + for round in 0..=self.stories.len() { + if round > 0 { + live = self.live_items(); + } + let dangling: Vec = + self.stories.iter().filter(|(_, st)| st.frames.iter().any(|f| !live.contains(f))).map(|(id, _)| *id).collect(); + if dangling.is_empty() { + break; + } + let mut shown = HashSet::new(); + for sp in self.spreads.iter().chain(&self.parents) { + for it in &sp.items { + it.walk(&mut |i| shown.extend(i.text_frame().map(|t| t.story))); + } + } + for st in self.stories.values() { + items(st, 0, &mut |i| shown.extend(i.text_frame().map(|t| t.story))); + } + for sid in dangling { + let Some(st) = self.stories.get_mut(&sid) else { continue }; + let st = Arc::make_mut(st); + st.frames.retain(|f| live.contains(f)); + changed += 1; + if st.frames.is_empty() && !shown.contains(&sid) { + self.stories.remove(&sid); + } + } + } + // Anchored text frames their stories don't thread: copies, or frames whose story went. + let mut frames = Vec::new(); + for (owner, st) in &self.stories { + items(st, 0, &mut |i| frames.extend(i.text_frame().map(|t| (*owner, i.id, t.story)))); + } + for (owner, fid, sid) in frames { + let story = match self.stories.get_mut(&sid) { + Some(st) if st.frames.contains(&fid) => continue, + Some(st) if st.frames.is_empty() => { + Arc::make_mut(st).frames.push(fid); + changed += 1; + continue; + } + Some(st) => { + let mut copy = (**st).clone(); + let id = StoryId(self.alloc()); + copy.id = id; + // Only the stories the copy reaches, as they are now (copies made for earlier + // frames included). + let reachable = self.anchored_stories(©); + self.adopt(&mut |f| items_mut(&mut copy, 0, f), &|s| reachable.get(&s).cloned(), None, &mut vec![sid], &mut HashMap::new()); + copy.frames = vec![fid]; + self.stories.insert(id, Arc::new(copy)); + id + } + None => { + let id = StoryId(self.alloc()); + let mut st = Story::new(id); + st.frames = vec![fid]; + self.stories.insert(id, Arc::new(st)); + id + } + }; + if let Some(st) = self.stories.get_mut(&owner) { + let st = Arc::make_mut(st); + items_mut(st, 0, &mut |i| { + if i.id == fid + && let Some(t) = i.text_frame_mut() + { + t.story = story; + } + }); + st.rev = st.rev.wrapping_add(1); + } + changed += 1; + } + changed + } +} + +#[cfg(test)] +mod tests { + use designcraft_geom::Rect; + + use super::*; + use crate::build::NewDocument; + use crate::{ParaFormat, SpreadRef}; + + /// A document with a text frame (story returned) on its first page. + fn doc() -> (Document, StoryId) { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, "Host ", ParaFormat::default()).unwrap(); + (d, sid) + } + + /// Anchor, at the end of `host`, a text frame showing a new story with `text`. + fn anchor(d: &mut Document, host: StoryId, text: &str) -> (StoryId, ItemId) { + let lid = d.default_layer(); + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 100.0, 40.0), lid, text, ParaFormat::default()).unwrap(); + let it = d.item(fid).unwrap().clone(); + d.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != fid); + let h = d.story_mut(host).unwrap(); + let n = h.len(); + h.insert_object(n, AnchoredObject::new(it, AnchorPosition::default())); + (sid, fid) + } + + /// The stories the anchored text frames show, with their texts. + fn shown(d: &Document) -> Vec<(StoryId, String)> { + d.anchored_items().iter().filter_map(|(_, i)| i.text_frame()).map(|t| (t.story, d.story(t.story).unwrap().text.clone())).collect() + } + + /// Stories shared between layers (two per layer, each anchoring a frame showing each story of + /// the next layer) are copied once each when their text is copied, and each copy threads the + /// copied frames that show it, as the original does. Copying them again for every frame that + /// shows them doubled the work with every layer. + #[test] + fn a_graph_of_shared_stories_is_copied_once_per_story() { + const LAYERS: usize = 12; + let (mut d, host) = doc(); + let lid = d.default_layer(); + let layers: Vec<[StoryId; 2]> = (0..LAYERS) + .map(|_| { + [0, 1].map(|_| { + let id = StoryId(d.alloc()); + d.stories.insert(id, Arc::new(Story::new(id))); + id + }) + }) + .collect(); + // Anchor at the end of `host` a text frame showing `shows`, threaded to it. + let anchor_showing = |d: &mut Document, host: StoryId, shows: StoryId| { + let (fid, own) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 100.0, 40.0), lid, "", ParaFormat::default()).unwrap(); + let mut it = d.item(fid).unwrap().clone(); + d.spread_mut(SpreadRef::Doc(0)).unwrap().items.retain(|i| i.id != fid); + d.stories.remove(&own); + it.text_frame_mut().unwrap().story = shows; + Arc::make_mut(d.stories.get_mut(&shows).unwrap()).frames.push(fid); + let h = d.story_mut(host).unwrap(); + let n = h.len(); + h.insert_object(n, AnchoredObject::new(it, AnchorPosition::default())); + }; + for &top in &layers[0] { + anchor_showing(&mut d, host, top); + } + for k in 0..LAYERS - 1 { + for s in layers[k] { + for t in layers[k + 1] { + anchor_showing(&mut d, s, t); + } + } + } + let mut copy = d.story(host).unwrap().extract(0..d.story(host).unwrap().len()); + let stories = d.anchored_stories(©); + assert_eq!(stories.len(), 2 * LAYERS); + let before: HashSet = d.stories.keys().copied().collect(); + d.adopt_anchored(&mut copy, &|id| stories.get(&id).cloned()); + let added: Vec = d.stories.keys().copied().filter(|id| !before.contains(id)).collect(); + assert_eq!(added.len(), 2 * LAYERS, "one copy per story"); + // Below the top layer each copy threads the two copied frames that show it. + let threads: Vec = added.iter().map(|id| d.story(*id).unwrap().frames.len()).collect(); + assert_eq!(threads.iter().filter(|&&n| n == 2).count(), 2 * (LAYERS - 1), "{threads:?}"); + assert_eq!(threads.iter().filter(|&&n| n == 1).count(), 2, "{threads:?}"); + } + + /// The repair's trigger: typing in a story with anchored frames (or in theirs) and moving + /// frames leave nothing to repair; copying text with anchored frames, deleting them with + /// their text or their host frame, and removing a story they show do. + #[test] + fn only_edits_that_copy_or_remove_anchored_items_trigger_the_repair() { + let (mut d, host) = doc(); + let (inner, _) = anchor(&mut d, host, "Inner "); + let old = d.clone(); + assert!(!d.anchored_items_changed_since(&old), "nothing changed"); + // Typing in the host and in the anchored frame's story; moving the host frame. + d.story_mut(host).unwrap().insert(0, "Typed "); + d.story_mut(inner).unwrap().insert(0, "Typed "); + let frame = d.story(host).unwrap().frames[0]; + d.item_mut(frame).unwrap().xf = designcraft_geom::Affine::translate((10.0, 0.0)); + assert!(!d.anchored_items_changed_since(&old), "typing and moving"); + d.check().unwrap(); + // Text with the anchored frame copied in the document. + let mut copied = d.clone(); + let st = copied.story(host).unwrap(); + let slice = st.extract(0..st.len()); + let h = copied.story_mut(host).unwrap(); + let n = h.len(); + h.insert_story(n, &slice); + assert!(copied.anchored_items_changed_since(&d), "copied"); + // Deleted with its text, or with the story it is anchored in. + let mut deleted = d.clone(); + let h = deleted.story_mut(host).unwrap(); + let n = h.len(); + h.delete(0..n); + assert!(deleted.anchored_items_changed_since(&d), "deleted with its text"); + let mut gone = d.clone(); + gone.stories.remove(&host); + assert!(gone.anchored_items_changed_since(&d), "deleted with its host's story"); + // The story it shows removed: the frame needs one. + let mut lost = d.clone(); + lost.stories.remove(&inner); + assert!(lost.anchored_items_changed_since(&d), "its story removed"); + // An ordinary text frame added, or text typed in a document without anchored items. + let (mut plain, sid) = doc(); + let before = plain.clone(); + plain.story_mut(sid).unwrap().insert(0, "Typed "); + let lid = plain.default_layer(); + plain.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 100.0, 40.0), lid, "New", ParaFormat::default()).unwrap(); + assert!(!plain.anchored_items_changed_since(&before)); + } + + #[test] + fn copied_anchored_text_frames_get_stories_of_their_own() { + let (mut d, host) = doc(); + let (inner, _) = anchor(&mut d, host, "Inner "); + anchor(&mut d, inner, "Deeper"); + d.check().unwrap(); + assert_eq!(d.fix_anchored_text_frames(), 0); + // Text copied in the document: the same anchored frames twice. + let copy = d.story(host).unwrap().extract(0..d.story(host).unwrap().len()); + let h = d.story_mut(host).unwrap(); + let n = h.len(); + h.insert_story(n, ©); + assert!(d.check().is_err()); + assert!(d.fix_anchored_text_frames() > 0); + d.check().unwrap(); + let shown = shown(&d); + assert_eq!(shown.len(), 4, "{shown:?}"); + assert_eq!(shown.iter().map(|(s, _)| *s).collect::>().len(), 4, "a story of its own each"); + assert_eq!(shown.iter().filter(|(_, t)| t == "Deeper").count(), 2, "{shown:?}"); + assert_eq!(shown.iter().filter(|(_, t)| t.starts_with("Inner ")).count(), 2, "{shown:?}"); + } + + /// The repair bumps the revisions of the stories it changes; a revision at its maximum wraps + /// around instead of overflowing. + #[test] + fn the_repair_wraps_story_revisions_at_their_maximum() { + let (mut d, host) = doc(); + anchor(&mut d, host, "Inner "); + let copy = d.story(host).unwrap().extract(0..d.story(host).unwrap().len()); + let h = d.story_mut(host).unwrap(); + let n = h.len(); + h.insert_story(n, ©); + Arc::make_mut(d.stories.get_mut(&host).unwrap()).rev = u64::MAX; + assert!(d.fix_anchored_text_frames() > 0); + d.check().unwrap(); + assert_ne!(d.story(host).unwrap().rev, u64::MAX); + } + + #[test] + fn deleted_anchored_text_frames_take_their_stories_along() { + // With the text they are anchored in. + let (mut d, host) = doc(); + let (inner, _) = anchor(&mut d, host, "Inner "); + let (deeper, _) = anchor(&mut d, inner, "Deeper"); + let h = d.story_mut(host).unwrap(); + let n = h.len(); + h.delete(0..n); + assert!(d.check().is_err()); + d.fix_anchored_text_frames(); + d.check().unwrap(); + assert!(d.story(inner).is_none() && d.story(deeper).is_none()); + // With the frame they are anchored in. + let (mut d, host) = doc(); + let (inner, _) = anchor(&mut d, host, "Inner "); + let frame = d.story(host).unwrap().frames[0]; + d.remove_item(frame).unwrap(); + assert!(d.check().is_err()); + d.fix_anchored_text_frames(); + d.check().unwrap(); + assert!(d.story(inner).is_none()); + // An unplaced story stays. + let (mut d, _) = doc(); + let sid = StoryId(d.alloc()); + d.stories.insert(sid, Arc::new(Story::new(sid))); + assert_eq!(d.fix_anchored_text_frames(), 0); + assert!(d.story(sid).is_some()); + } + + #[test] + fn anchored_text_frames_without_their_stories_get_one() { + // Missing: an empty story of its own. + let (mut d, host) = doc(); + let (inner, fid) = anchor(&mut d, host, "Inner"); + d.stories.remove(&inner); + assert!(d.check().is_err()); + d.fix_anchored_text_frames(); + d.check().unwrap(); + let t = d.anchored_item(fid).unwrap().text_frame().unwrap().story; + assert!(d.story(t).unwrap().text.is_empty()); + // Without frames (its frame was cut and pasted back): threaded to it again. + let (mut d, host) = doc(); + let (inner, fid) = anchor(&mut d, host, "Inner"); + Arc::make_mut(d.stories.get_mut(&inner).unwrap()).frames.clear(); + d.fix_anchored_text_frames(); + d.check().unwrap(); + assert_eq!(d.story(inner).unwrap().frames, vec![fid]); + } + + #[test] + fn an_anchored_text_frame_copied_into_its_own_story_does_not_show_itself() { + let (mut d, host) = doc(); + let (inner, _) = anchor(&mut d, host, "Inner "); + let obj = (*d.story(host).unwrap().objects[0]).clone(); + let st = d.story_mut(inner).unwrap(); + let n = st.len(); + st.insert_object(n, obj); + assert!(d.check().is_err()); + d.fix_anchored_text_frames(); + d.check().unwrap(); + } + + /// Text copied from another document takes along the graphics placed in the stories its + /// anchored text frames show: the graphic keeps its own file (a.pdf), though its asset id + /// names another file (b.pdf) in the target document. + #[test] + fn adopted_stories_take_their_graphics_along_from_another_document() { + use crate::{Asset, Content, Graphic, Shape}; + let pdf = |id: AssetId, name: &str| Asset { + id, + name: name.into(), + mime: "application/pdf".into(), + data: Arc::new(format!("%PDF-1.7 {name}").into_bytes()), + ..Default::default() + }; + let (mut a, host) = doc(); + let (inner, _) = anchor(&mut a, host, "Inner "); + let aid = AssetId(a.alloc()); + a.assets.insert(aid, Arc::new(pdf(aid, "a.pdf"))); + let mut g = + Item::new(ItemId(a.alloc()), a.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0))); + g.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (20.0, 20.0), + xf: designcraft_geom::Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + let st = a.story_mut(inner).unwrap(); + let n = st.len(); + st.insert_object(n, AnchoredObject::new(g, AnchorPosition::default())); + a.check().unwrap(); + // The target document has b.pdf under the same id (allocated there too). + let (mut b, target) = doc(); + while b.alloc() < aid.0 {} + b.assets.insert(aid, Arc::new(pdf(aid, "b.pdf"))); + let src = a.story(host).unwrap(); + let mut slice = src.extract(0..src.len()); + b.adopt_anchored_from(&mut slice, &a); + let t = b.story_mut(target).unwrap(); + let n = t.len(); + t.insert_story(n, &slice); + b.check().unwrap(); + let placed: Vec = b + .anchored_items() + .iter() + .filter_map(|(_, i)| i.graphic()) + .map(|g| b.assets.get(&g.asset).map_or_else(|| "missing".to_string(), |a| a.name.clone())) + .collect(); + assert_eq!(placed, ["a.pdf"], "the copied story's graphic places its own file"); + assert_eq!(b.assets.get(&aid).map(|a| a.name.as_str()), Some("b.pdf"), "the target's own asset is left alone"); + } + + #[test] + fn adopted_text_brings_copies_of_its_anchored_stories() { + let (mut a, host) = doc(); + let (inner, _) = anchor(&mut a, host, "Inner "); + anchor(&mut a, inner, "Deeper"); + // Another document, whose ids are the same as the first one's. + let (mut b, target) = doc(); + let (other, _) = anchor(&mut b, target, "Other"); + assert!(b.story(inner).is_some(), "ids collide"); + let src = a.story(host).unwrap(); + let mut slice = src.extract(0..src.len()); + let stories = a.anchored_stories(&slice); + assert_eq!(stories.len(), 2); + b.adopt_anchored(&mut slice, &|id| stories.get(&id).cloned()); + let t = b.story_mut(target).unwrap(); + let n = t.len(); + t.insert_story(n, &slice); + b.check().unwrap(); + let mut texts: Vec = shown(&b).into_iter().map(|(_, t)| t).collect(); + texts.sort(); + assert_eq!(texts, ["Deeper", "Inner \u{E00E}", "Other"]); + assert_eq!(b.story(other).unwrap().text, "Other"); + } +} diff --git a/crates/doc/src/arrow.rs b/crates/doc/src/arrow.rs index e3f3387fe..432cd4b93 100644 --- a/crates/doc/src/arrow.rs +++ b/crates/doc/src/arrow.rs @@ -75,7 +75,12 @@ fn end_of(seg: &PathSeg, at_start: bool) -> (Point, Vec2) { let mut d = match seg { PathSeg::Line(l) => l.p1 - l.p0, PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), - PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), + PathSeg::Cubic(c) => { + // A handle sitting on its anchor has no direction: the tangent then points at the next + // control point of the segment that differs from the end point. + let pts = if at_start { [c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0] } else { [c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0] }; + pts.into_iter().find(|v| v.hypot() >= 1e-9).unwrap_or(Vec2::ZERO) + } }; if d.hypot() < 1e-9 { d = seg.end() - seg.start(); diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 6ddd52b1e..0d100cee7 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -9,7 +9,7 @@ use std::hash::BuildHasher; use std::sync::Arc; use crate::story::Story; -use crate::{Asset, AssetId, Content, Document, Item}; +use crate::{Asset, AssetId, Content, Document, Fingerprint, Item}; /// Nesting limit for groups, anchored objects, table cells and notes. A document nested deeper /// is treated as using every asset, so nothing it references is merged away or pruned. @@ -82,6 +82,14 @@ impl Document { } impl Asset { + /// Link the asset to another file, or to none (embedded). The size and time kept with a link + /// ([`Asset::link_fingerprint`]) never outlive it: `loaded` is the new file's when `data` was + /// read from it, and `None` when it wasn't (a copy of the bytes, a file found again by name). + pub fn set_link(&mut self, link: Option, loaded: Option) { + self.link_fingerprint = loaded.filter(|_| link.is_some()); + self.link = link; + } + /// Whether `other` holds the same file: the same type, PDF page and crop, link and bytes. Files at /// different paths stay apart even with identical bytes, since each can be updated or /// relinked on its own. Assets without bytes (a link that couldn't be read) are the same only diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b569610bc..8d1f99456 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -567,6 +567,13 @@ pub struct Asset { /// Original location on disk (Links panel), if linked. #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option, + /// The size and modification time the linked file had when `data` was read from it, or when + /// it was last found to hold `data`: link status compares these first instead of reading a + /// large file from a network share again. They are the file's only together with `link` and + /// `data`: a new link goes through [`Asset::set_link`], and whoever replaces `data` sets or + /// clears them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link_fingerprint: Option, #[serde(skip)] pub data: Arc>, /// Pixel size for raster images. diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index d14282b2c..adac7ac0d 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -433,6 +433,21 @@ impl Spread { None => self.bounds().center().x, } } + /// The edges where two pages of the spread meet, left to right: the x of each shared edge and + /// the vertical span of the two pages together. Pages are taken in position order, so + /// right-to-left binding and any page count work; pages that don't touch (an imported spread + /// can keep gaps between them) share no edge. + pub fn page_seams(&self) -> Vec<(f64, f64, f64)> { + /// Facing edges this close (in points) meet: page positions are sums of widths. + const TOUCH: f64 = 1e-6; + let mut pages: Vec = self.pages.iter().map(Page::bounds).collect(); + pages.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + pages + .windows(2) + .filter(|w| (w[1].x0 - w[0].x1).abs() <= TOUCH * w[0].x1.abs().max(1.0)) + .map(|w| (w[1].x0, w[0].y0.min(w[1].y0), w[0].y1.max(w[1].y1))) + .collect() + } /// Indices of the leftmost and rightmost pages. A right-to-left bound spread lists its right /// page first, so list order says nothing about position. pub fn outer_page_indices(&self) -> Option<(usize, usize)> { @@ -688,6 +703,28 @@ mod tests { assert_eq!(s.page_at_x(5000.0), Some(1)); } + /// Seams are where two pages' facing edges meet, in position order: one per shared edge for + /// any page count or binding, none across a gap, spanning both pages' heights. + #[test] + fn page_seams_are_shared_edges_only() { + let spread = |pages| Spread { id: SpreadId(1), pages, items: vec![], parent: None, allow_shuffle: true }; + let full = (0.0, 792.0); + // A facing pair, listed in either order (right-to-left binding lists the right page first). + for pages in [vec![page(PageSide::Left, 0.0), page(PageSide::Right, 612.0)], vec![page(PageSide::Right, 612.0), page(PageSide::Left, 0.0)]] { + assert_eq!(spread(pages).page_seams(), vec![(612.0, full.0, full.1)]); + } + // Three pages: two seams; a single page: none. + let three = spread(vec![page(PageSide::Left, 0.0), page(PageSide::Right, 612.0), page(PageSide::Right, 1224.0)]); + assert_eq!(three.page_seams(), vec![(612.0, 0.0, 792.0), (1224.0, 0.0, 792.0)]); + assert!(spread(vec![page(PageSide::Single, 0.0)]).page_seams().is_empty()); + // A gap between the pages, as an import can keep: no shared edge. + assert!(spread(vec![page(PageSide::Left, 0.0), page(PageSide::Right, 700.0)]).page_seams().is_empty()); + // Pages of different heights: the seam spans both. + let mut tall = page(PageSide::Right, 612.0); + tall.height = 1000.0; + assert_eq!(spread(vec![page(PageSide::Left, 0.0), tall]).page_seams(), vec![(612.0, 0.0, 1000.0)]); + } + /// The outside bleed goes on the leftmost and rightmost pages, also when right-to-left /// binding lists the right page first. #[test] diff --git a/crates/doc/tests/autocraft_regression_2514.rs b/crates/doc/tests/autocraft_regression_2514.rs new file mode 100644 index 000000000..addd40ef2 --- /dev/null +++ b/crates/doc/tests/autocraft_regression_2514.rs @@ -0,0 +1,28 @@ +use designcraft_doc::arrow::apply; +use designcraft_doc::geom::BezPath; +use designcraft_doc::geom::kurbo::Shape; +use designcraft_doc::{Arrowhead, Stroke}; + +#[test] +fn start_head_follows_tangent_when_start_handle_is_missing() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.curve_to((0.0, 0.0), (100.0, 0.0), (100.0, 100.0)); + let st = Stroke { weight: 1.0, start: Arrowhead::Bar, ..Stroke::default() }; + let (_, heads) = apply(&bp, &st, false).unwrap(); + // The curve leaves heading along +x, so the bar at the start stands upright. + let b = heads[0].path.bounding_box(); + assert!(b.height() > b.width() * 3.0, "{b:?}"); +} + +#[test] +fn end_head_follows_tangent_when_end_handle_is_missing() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.curve_to((100.0, 0.0), (100.0, 100.0), (100.0, 100.0)); + let st = Stroke { weight: 1.0, end: Arrowhead::Bar, ..Stroke::default() }; + let (_, heads) = apply(&bp, &st, false).unwrap(); + // The curve arrives heading along +y, so the bar at the end lies across, horizontally. + let b = heads[0].path.bounding_box(); + assert!(b.width() > b.height() * 3.0, "{b:?}"); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index ed77b3319..03b6082db 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -33,6 +33,7 @@ mod layout; pub mod library; mod linked; mod links; +pub use links::{LinkListChecks, list_document_links, preview_document_links, remember_link_files}; mod liquid; mod lists; mod load_styles; diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index 48bb8bb85..d55cdf753 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -229,6 +229,13 @@ fn release(s: &mut Session, p: &Value) -> Result { if d.layer(it.layer).is_none() { it.layer = d.default_layer(); } + // A text frame keeps its place in its story's thread under its new id. + if let Some(st) = it.text_frame().and_then(|t| d.story_mut(t.story)) { + match st.frames.iter_mut().find(|f| **f == obj.item.id) { + Some(f) => *f = it.id, + None => st.frames.push(it.id), + } + } let id = d.insert_item(spread, it, None)?; let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let len = OBJECT_MARK.len_utf8(); @@ -337,3 +344,251 @@ mod custom_tests { )); } } + +#[cfg(test)] +mod text_frame_tests { + use designcraft_doc::{ParaFormat, StoryId}; + use designcraft_geom::Rect; + use serde_json::json; + + use super::*; + + /// Anchor, at `pos` of `host`, a 200 × 40 pt text frame showing a new story with `text`. + fn anchor(d: &mut Document, host: StoryId, pos: usize, text: &str) -> designcraft_doc::Result<(StoryId, ItemId)> { + let layer = d.default_layer(); + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 40.0), layer, text, ParaFormat::default())?; + let item = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.clone(); + d.spread_mut(SpreadRef::Doc(0)).ok_or(designcraft_doc::DocError::NoItem(fid))?.items.retain(|i| i.id != fid); + d.story_mut(host).ok_or(designcraft_doc::DocError::NoStory(host))?.insert_object(pos, AnchoredObject::new(item, AnchorPosition::default())); + Ok((sid, fid)) + } + + /// A new document with a text frame whose story holds, after "Host ", an anchored text frame + /// showing "Anchored words " and, in that, one showing "Deeper". Returns the host story, the + /// anchored story and the anchored frame's id. + fn session() -> (Session, StoryId, StoryId, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Host end"})).unwrap(); + let host = StoryId(r["story"].as_u64().unwrap()); + let (sid, id) = s + .edit(|d, _| { + let (sid, id) = anchor(d, host, 5, "Anchored words ")?; + anchor(d, sid, 15, "Deeper")?; + Ok((sid, id)) + }) + .unwrap(); + s.doc().unwrap().doc.check().unwrap(); + (s, host, sid, id) + } + + /// The document is valid (and opens again once saved); returns the texts its anchored text + /// frames show, sorted, after checking each has a story of its own. + fn shown(s: &Session) -> Vec { + let d = &s.doc().unwrap().doc; + d.check().unwrap(); + designcraft_format::load(&designcraft_format::save(d).unwrap()).unwrap(); + let stories: Vec = d.anchored_items().iter().filter_map(|(_, i)| i.text_frame()).map(|t| t.story).collect(); + let distinct: std::collections::HashSet<_> = stories.iter().collect(); + assert_eq!(distinct.len(), stories.len(), "a story of its own each"); + let mut texts: Vec = stories.iter().map(|sid| d.story(*sid).unwrap().text.clone()).collect(); + texts.sort(); + texts + } + + const SHOWN: [&str; 2] = ["Anchored words \u{E00E}", "Deeper"]; + + #[test] + fn releasing_an_anchored_text_frame_threads_its_story_to_it() { + let (mut s, host, sid, _) = session(); + s.execute("anchored.release", &json!({"story": host.0, "index": 0})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + d.check().unwrap(); + let id = s.doc().unwrap().selection.items[0]; + assert_eq!(d.story(sid).unwrap().frames, vec![id]); + assert_eq!(d.item(id).unwrap().text_frame().unwrap().story, sid); + assert!(s.cache.get(&d, sid, None).frame(id).is_some_and(|f| !f.lines.is_empty())); + assert_eq!(shown(&s), ["Deeper"]); + } + + #[test] + fn copied_anchored_text_frames_get_stories_of_their_own() { + let (mut s, host, sid, id) = session(); + // Text copied and pasted in the same story. + s.execute("text.select", &json!({"story": host.0, "anchor": 0, "focus": 9})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let len = s.doc().unwrap().doc.story(host).unwrap().len(); + s.execute("text.select", &json!({"story": host.0, "anchor": len, "focus": len})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(shown(&s), ["Anchored words \u{E00E}", "Anchored words \u{E00E}", "Deeper", "Deeper"]); + // The original keeps its frame and story. + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().frames, vec![id]); + // The host frame duplicated. + let frame = s.doc().unwrap().doc.story(host).unwrap().frames[0]; + s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); + s.execute("edit.duplicate", &json!({})).unwrap(); + assert_eq!(shown(&s).len(), 8); + // Text dragged with a copy. + s.execute("text.select", &json!({"story": host.0, "anchor": 0, "focus": 9})).unwrap(); + s.edit(|d, _| { + let st = d.story_mut(host).ok_or(designcraft_doc::DocError::NoStory(host))?; + let piece = st.extract(0..9); + st.insert_story(0, &piece); + Ok(()) + }) + .unwrap(); + assert_eq!(shown(&s).len(), 10); + } + + /// The endnote frame lists the endnotes of anchored stories copied or deleted with their + /// text at once: it used to be brought up to date before the copies were made. + #[test] + fn the_endnote_frame_follows_anchored_stories_copied_and_deleted() { + let (mut s, host, sid, _) = session(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("endnote.insert", &json!({"text": "A note"})).unwrap(); + let notes = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.story(d.endnote_story.unwrap()).unwrap().text.matches("A note").count() + }; + assert_eq!(notes(&s), 1); + s.execute("text.select", &json!({"story": host.0, "anchor": 0, "focus": 9})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let len = s.doc().unwrap().doc.story(host).unwrap().len(); + s.execute("text.select", &json!({"story": host.0, "anchor": len, "focus": len})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(notes(&s), 2, "copied"); + s.execute("text.select", &json!({"story": host.0, "anchor": 0, "focus": 9})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + assert_eq!(notes(&s), 1, "deleted"); + } + + /// Text with an anchored text frame pasted into the generated endnote story is replaced when + /// the endnotes are set again; the repair after it leaves no story threaded to the dropped frame. + #[test] + fn anchored_text_frames_pasted_into_the_endnote_story_leave_a_valid_document() { + let (mut s, host, sid, _) = session(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("endnote.insert", &json!({"text": "A note"})).unwrap(); + s.execute("text.select", &json!({"story": host.0, "anchor": 0, "focus": 9})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let notes = s.doc().unwrap().doc.endnote_story.unwrap(); + let len = s.doc().unwrap().doc.story(notes).unwrap().len(); + s.execute("text.select", &json!({"story": notes.0, "anchor": len, "focus": len})).unwrap(); + let _ = s.execute("edit.paste", &json!({})); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn deleted_anchored_text_frames_take_their_stories_along() { + let (mut s, host, sid, _) = session(); + let stories = s.doc().unwrap().doc.stories.len(); + // Cut with the text it is in, and pasted back. + s.execute("text.select", &json!({"story": host.0, "anchor": 0, "focus": 9})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + assert!(shown(&s).is_empty()); + assert!(s.doc().unwrap().doc.story(sid).is_none()); + assert_eq!(s.doc().unwrap().doc.stories.len(), stories - 2); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(shown(&s), SHOWN); + assert_eq!(s.doc().unwrap().doc.stories.len(), stories); + // Deleted with the frame it is anchored in; undo brings it back. + let frame = s.doc().unwrap().doc.story(host).unwrap().frames[0]; + s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(shown(&s).is_empty()); + assert!(s.doc().unwrap().doc.stories.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(shown(&s), SHOWN); + } + + #[test] + fn anchored_text_frames_paste_into_another_document() { + let (mut s, host, _, _) = session(); + let frame = s.doc().unwrap().doc.story(host).unwrap().frames[0]; + // A second document, whose ids are those of the first one. + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Other "})).unwrap(); + let other = StoryId(r["story"].as_u64().unwrap()); + s.edit(|d, _| Ok(anchor(d, other, 6, "Elsewhere")?)).unwrap(); + // Copied text. + s.set_active(0); + s.execute("text.select", &json!({"story": host.0, "anchor": 0, "focus": 9})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.set_active(1); + let len = s.doc().unwrap().doc.story(other).unwrap().len(); + s.execute("text.select", &json!({"story": other.0, "anchor": len, "focus": len})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(shown(&s), ["Anchored words \u{E00E}", "Deeper", "Elsewhere"]); + // A copied frame. + s.set_active(0); + s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.set_active(1); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(shown(&s), ["Anchored words \u{E00E}", "Anchored words \u{E00E}", "Deeper", "Deeper", "Elsewhere"]); + } + + /// Place a 4 × 4 pt graphic of grey `v`; returns its frame and its asset. + fn place(s: &mut Session, v: u8) -> (ItemId, designcraft_doc::AssetId) { + let png = designcraft_render::Rendered { width: 4, height: 4, pixels: vec![v; 64] }.to_png(); + let r = + s.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&png), "name": format!("{v}.png"), "x": 72, "y": 72})).unwrap(); + (ItemId(r["id"].as_u64().unwrap()), designcraft_doc::AssetId(r["asset"].as_u64().unwrap())) + } + + /// The files the graphics anchored in the active document show (in its anchored text frames' + /// stories too), sorted; `None` for a graphic whose asset is missing. + fn anchored_files(s: &Session) -> Vec>>> { + let d = &s.doc().unwrap().doc; + d.check().unwrap(); + let mut files: Vec<_> = + d.anchored_items().iter().filter_map(|(_, i)| i.graphic()).map(|g| d.assets.get(&g.asset).map(|a| a.data.clone())).collect(); + files.sort(); + files + } + + /// Copied text pasted into another document takes along the files of the graphics anchored + /// in it and in the stories of its anchored text frames: their asset ids may name other files + /// there, or none. + #[test] + fn pasted_text_takes_its_graphics_into_another_document() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let (inline, a) = place(&mut s, 10); + let (nested, _) = place(&mut s, 20); + let r = s.execute("frame.create", &json!({"rect": [72, 200, 400, 500], "content": "text", "text": "Host end"})).unwrap(); + let host = StoryId(r["story"].as_u64().unwrap()); + let (sid, _) = s.edit(|d, _| Ok(anchor(d, host, 5, "Anchored words ")?)).unwrap(); + // A graphic in the copied text and one in its anchored text frame's story. + s.execute("anchored.insert", &json!({"ids": [inline.0], "story": host.0, "pos": 0})).unwrap(); + s.execute("anchored.insert", &json!({"ids": [nested.0], "story": sid.0, "pos": 0})).unwrap(); + let files = anchored_files(&s); + assert_eq!(files.len(), 2); + assert!(files.iter().all(Option::is_some)); + let len = s.doc().unwrap().doc.story(host).unwrap().len(); + s.execute("text.select", &json!({"story": host.0, "anchor": 0, "focus": len})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let plain = s.text_clipboard.as_ref().unwrap().1.clone(); + + // Another document whose own graphic has the asset id of the first copied one. + s.execute("file.new", &json!({})).unwrap(); + let (_, b) = place(&mut s, 30); + assert_eq!(a, b, "fresh documents allocate the same first asset id"); + let own = s.doc().unwrap().doc.assets.get(&b).unwrap().data.clone(); + let r = s.execute("frame.create", &json!({"rect": [72, 200, 400, 500], "content": "text", "text": "Other "})).unwrap(); + let other = StoryId(r["story"].as_u64().unwrap()); + // Into text. + s.execute("text.select", &json!({"story": other.0, "anchor": 6, "focus": 6})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(anchored_files(&s), files); + // Into a new text frame. + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.execute("edit.paste", &json!({"text": plain})).unwrap(); + let twice: Vec<_> = files.iter().flat_map(|f| [f.clone(), f.clone()]).collect(); + assert_eq!(anchored_files(&s), twice); + // The document's own graphic keeps its file. + assert_eq!(s.doc().unwrap().doc.assets.get(&b).unwrap().data, own); + } +} diff --git a/crates/engine/src/cmd/datamerge.rs b/crates/engine/src/cmd/datamerge.rs index 5b42e6c1e..fb8d572cb 100644 --- a/crates/engine/src/cmd/datamerge.rs +++ b/crates/engine/src/cmd/datamerge.rs @@ -19,13 +19,11 @@ 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, 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, -}; +use parse::{Table, TileInput, extension, relative_between, select_records, status_of, table_from_bytes, table_from_grid, table_from_objects, tile}; #[cfg(not(target_arch = "wasm32"))] pub(crate) use parse::fingerprint_from; +pub(crate) use parse::fingerprint_of; pub(crate) fn fingerprint_from_path(path: &str) -> Option { fingerprint_of(Path::new(path)) diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index ae0b4e6b4..a7b4a0ba6 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -334,13 +334,18 @@ fn typed_reps( fn find_markers(text: &str) -> Vec<(usize, usize, String)> { let mut out = Vec::new(); let mut i = 0; + // The next `>>` at or after the `<<` being looked at: found once, kept until passed, so the + // text is scanned once however many `<<` it holds (not again from every one of them). + let mut close: Option = Some(0); while i < text.len() { - if text[i..].starts_with("<<") - && let Some(rel) = text[i + 2..].find(">>") - { - let name = &text[i + 2..i + 2 + rel]; + if text[i..].starts_with("<<") { + if close.is_some_and(|c| c < i + 2) { + close = text[i + 2..].find(">>").map(|rel| i + 2 + rel); + } + let Some(c) = close else { break }; + let name = &text[i + 2..c]; if !name.is_empty() && !name.contains(['<', '>', '\n']) { - let end = i + 2 + rel + 2; + let end = c + 2; out.push((i, end, name.to_string())); i = end; continue; @@ -570,6 +575,25 @@ fn cell_of<'a>(fields: &[DataField], row: &'a [String], name: &str) -> &'a str { fields.iter().position(|f| f.name == name).and_then(|i| row.get(i)).map(String::as_str).unwrap_or("") } +#[cfg(test)] +mod marker_tests { + use super::find_markers; + + #[test] + fn markers_are_found_in_one_pass() { + let found = find_markers; + assert_eq!(found("a <> b <>"), [(2, 10, "Name".to_string()), (13, 18, "x".to_string())]); + assert_eq!(found("<<>> <> <>"), [(13, 19, "ok".to_string())]); + assert_eq!(found("<<<>"), [(2, 7, "a".to_string())]); + // Many `<<` with no `>>` to close them: linear, not a search from each of them. + let open = "<<".repeat(200_000); + let t = std::time::Instant::now(); + assert!(found(&open).is_empty()); + assert!(found(&format!("{open}>>")).is_empty(), "a name made of `<` isn't one"); + assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed()); + } +} + #[cfg(test)] mod link_tests { use super::*; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a87c9b501..ef7e95224 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -54,10 +54,44 @@ fn copy_text(s: &mut Session) -> Result> { let Some(story) = st.doc.text_story(t.story, t.cell) else { return Ok(None) }; let slice = story.extract(t.range()); let text = plain(&slice); - s.text_clipboard = Some((Arc::new(slice), text.clone())); + // Anchored text frames take their stories along, and graphics their files. + let source = text_source(&st.doc, &slice); + s.text_clipboard = Some((Arc::new(slice), text.clone(), Arc::new(source))); Ok(Some(text)) } +/// What pasting `slice` (text copied from `doc`) needs from `doc`, also in another document: the +/// stories its anchored text frames show (and theirs) and the graphics it and those stories +/// place. Nothing on spreads. +fn text_source(doc: &designcraft_doc::Document, slice: &designcraft_doc::Story) -> designcraft_doc::Document { + let mut d = doc.clone(); + d.stories = doc.anchored_stories(slice); + let used: std::collections::HashSet = + std::iter::once(slice).chain(d.stories.values().map(|st| &**st)).flat_map(designcraft_doc::story_asset_refs).collect(); + d.assets.retain(|id, _| used.contains(id)); + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + Arc::make_mut(sp).items.clear(); + } + d +} + +/// Copied text `slice` pasted into `d`: the graphics it places (from `src`, its +/// [`text_source`]) under `d`'s ids, since `src`'s may name other files in another document; +/// fresh ids for its anchored items and copies of the stories its anchored text frames show. +fn adopt_text(d: &mut designcraft_doc::Document, slice: &mut designcraft_doc::Story, src: &designcraft_doc::Document) { + let refs: std::collections::BTreeSet = designcraft_doc::story_asset_refs(slice).into_iter().collect(); + let mut map = std::collections::HashMap::new(); + for a in refs { + // A graphic missing there stays missing: a fresh id, not one that may name a file here. + let here = d.adopt_asset(src, a).unwrap_or_else(|| designcraft_doc::AssetId(d.alloc())); + if here != a { + map.insert(a, here); + } + } + designcraft_doc::remap_story_assets(slice, &map); + d.adopt_anchored_from(slice, src); +} + /// Plain text for the system clipboard after copying objects: the copied text frames' stories, /// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the /// copied objects", not as text. @@ -123,12 +157,13 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< }); } } - let use_clip = formatted && clip.as_ref().is_some_and(|(_, p)| text.as_ref().is_none_or(|x| x.replace("\r\n", "\n") == *p)); + let use_clip = formatted && clip.as_ref().is_some_and(|(_, p, _)| text.as_ref().is_none_or(|x| x.replace("\r\n", "\n") == *p)); // Footnotes, endnotes, editorial notes, cross-references and tables cannot be pasted into cells or footnotes. - if use_clip && let Some((slice, _)) = clip { - let slice = if t.cell.is_some() { strip_objects(&slice) } else { (*slice).clone() }; - let slice_len = slice.text.len(); + if use_clip && let Some((slice, _, source)) = clip { + let mut slice = if t.cell.is_some() { strip_objects(&slice) } else { (*slice).clone() }; return s.edit(|d, sel| { + adopt_text(d, &mut slice, &source); + let slice_len = slice.text.len(); let (end, r) = { let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); @@ -146,7 +181,7 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< } // U+FFFC stands for objects with no text. let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text }; - let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?; + let text = text.or_else(|| clip.map(|(_, p, _)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?; let text = outside_text(&text)?; s.execute("text.insert", &json!({"text": text, "raw": true})) } @@ -162,8 +197,9 @@ fn outside_text(text: &str) -> Result { /// Pasted text with no insertion point: a new text frame holding it, as wide as the first column /// of the page's margin area and as tall as the text (at most the margin area's height), centred /// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With -/// `slice` (the text copied here), the frame's story is that formatted copy. -fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result { +/// `slice` (the text copied here, with its [`text_source`]), the frame's story is that formatted +/// copy. +fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<(&designcraft_doc::Story, &designcraft_doc::Document)>) -> Result { use designcraft_geom::{Point, Rect, shapes}; const LIMIT: f64 = 1.0e5; let sr = super::spread_param(p, "edit.paste")?; @@ -183,13 +219,15 @@ fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&desig let max_h = margins.height().max(12.0); let c = at.unwrap_or(Point::new(column.center().x, margins.center().y)); let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0); - let para = match slice.and_then(|sl| sl.paras.first()) { + let para = match slice.and_then(|(sl, _)| sl.paras.first()) { Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() }, None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() }, }; let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?; - if let Some(sl) = slice { - d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl); + if let Some((sl, source)) = slice { + let mut sl = sl.clone(); + adopt_text(d, &mut sl, source); + d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, &sl); } // Shrink to the composed text unless it already fills the frame. let composed = cache.get(d, sid, None); @@ -441,8 +479,8 @@ pub fn specs() -> Vec { // The placeholder of an object copy pastes the copied objects. if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) { // What our own text copy put on the clipboard keeps its formatting. - let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice); - return paste_text_frame(s, p, text, slice.as_deref()); + let clip = s.text_clipboard.clone().filter(|(_, plain, _)| text.replace("\r\n", "\n") == *plain); + return paste_text_frame(s, p, text, clip.as_ref().map(|(slice, _, source)| (&**slice, &**source))); } let Some(clip) = s.clipboard.clone() else { return Err(super::bad("edit.paste", "clipboard is empty")); diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index c6a581d5d..a30cb2321 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -90,7 +90,17 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { _ => PdfCrop::Crop, }; let mime = designcraft_render::image_mime(&bytes).to_string(); - let mut asset = Asset { page: pdf_page, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), pdf_crop, ..Default::default() }; + let mut asset = Asset { + page: pdf_page, + name, + mime, + link, + link_fingerprint: crate::cmd::links::loaded_fingerprint(before, &bytes), + data: Arc::new(bytes), + pixels: Some((pw, ph)), + pdf_crop, + ..Default::default() + }; // The graphic's natural size: the box in points (the whole page when the box can't be found, // and then the document says so). let (bw, bh) = match crop_pdf(&mut asset) { @@ -198,16 +208,25 @@ pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> { } pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option)> { + read_linked_source(p).map(|(bytes, name, link, _)| (bytes, name, link)) +} + +/// [`read_source`], with the size and time to keep with a linked file's bytes +/// ([`crate::cmd::links::loaded_fingerprint`]): the file's from before it is read, not those of a +/// later version that replaces it while a network or syncing source is being loaded. +fn read_linked_source(p: &Value) -> Result<(Vec, String, Option, Option)> { if let Some(b) = str_param(p, "base64") { - return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)); + return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None, None)); } let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?; + let before = crate::cmd::datamerge::fingerprint_from_path(path); #[cfg(not(target_arch = "wasm32"))] let b = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let b: Vec = vec![]; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); - Ok((b, name, Some(path.to_string()))) + let loaded = crate::cmd::links::loaded_fingerprint(before, &b); + Ok((b, name, Some(path.to_string()), loaded)) } fn eps_size(bytes: &[u8], command: &str) -> Result> { @@ -236,11 +255,11 @@ pub(crate) fn validate_placement(size: (f64, f64), frame: Rect, xf: Affine, comm } pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { - let (bytes, name, link) = read_source(p)?; + let (bytes, name, link, link_fingerprint) = read_linked_source(p)?; let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; let natural = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); let mime = designcraft_render::image_mime(&bytes).to_string(); - let asset = Asset { name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() }; + let asset = Asset { name, mime, link, link_fingerprint, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() }; load_cursor(s, asset, natural) } @@ -378,7 +397,7 @@ fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Re #[cfg(all(test, not(target_arch = "wasm32")))] pub(super) mod image_place_tests { - use designcraft_doc::{Content, ItemId}; + use designcraft_doc::{AssetId, Content, ItemId}; use designcraft_geom::Rect; use serde_json::{Value, json}; @@ -449,6 +468,29 @@ pub(super) mod image_place_tests { assert!(matches!(session.doc().unwrap().doc.item(ItemId(frame)).unwrap().content, Content::Graphic(_))); } + #[test] + fn place_load_remembers_the_fingerprint_captured_for_its_link() { + let image = image_file().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let load = |session: &mut Session| { + let loaded = session.execute("place.load", &json!({"path": image.path()})).unwrap(); + session.doc().unwrap().doc.assets[&AssetId(loaded["asset"].as_u64().unwrap())].clone() + }; + + // Written just now: another write in the same second would keep its time, so the time + // can't vouch for the bytes later and isn't kept. + let asset = load(&mut session); + assert_eq!(asset.link_fingerprint, None); + assert_eq!(asset.link.as_deref(), image.path().to_str()); + + // A file that has been as it is for a while is remembered by its size and time. + image.as_file().set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60)).unwrap(); + let expected = crate::cmd::datamerge::fingerprint_from(&std::fs::metadata(image.path()).unwrap()); + session.execute("file.new", &json!({})).unwrap(); + assert_eq!(load(&mut session).link_fingerprint, Some(expected)); + } + #[test] fn individual_placement_parameters_bypass_selected_frames() { let image = image_file().unwrap(); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 335773ee5..47b36b776 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -90,7 +90,7 @@ fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result Option> { #[cfg(not(target_arch = "wasm32"))] { - read_packaged_link(link, package).map(|(_, data)| data) + read_packaged_link(link, package).map(|(_, data, _)| data) } #[cfg(target_arch = "wasm32")] { @@ -174,8 +174,8 @@ fn import_report_with_previews( let read = |link: &str| -> Option> { #[cfg(not(target_arch = "wasm32"))] { - let (path, data) = read_packaged_link(link, package)?; - resolved.borrow_mut().insert(link.to_string(), path); + let (path, data, fingerprint) = read_packaged_link(link, package)?; + resolved.borrow_mut().insert(link.to_string(), (path, fingerprint)); Some(data) } #[cfg(target_arch = "wasm32")] @@ -191,9 +191,9 @@ fn import_report_with_previews( let document = { let mut document = document; for asset in document.assets.values_mut() { - if let Some(path) = asset.link.as_ref().and_then(|link| resolved.borrow().get(link).cloned()) { + if let Some((path, fingerprint)) = asset.link.as_ref().and_then(|link| resolved.borrow().get(link).cloned()) { // Relocated packages must also use the new path for preflight and link updates. - std::sync::Arc::make_mut(asset).link = Some(path); + std::sync::Arc::make_mut(asset).set_link(Some(path), fingerprint); } } resolve_packaged_links(&mut document, package); @@ -357,29 +357,41 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, package: Option<&s // A link relative to the document (what File › Package writes, `Links/logo.png`) is // held as the file it names, as every other link is, so preflight and updates find it. if std::path::Path::new(link).is_relative() { - std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned()); + // The same file under its full name: what is kept of it stays. + let loaded = asset.link_fingerprint; + std::sync::Arc::make_mut(asset).set_link(Some(path.to_string_lossy().into_owned()), loaded); } continue; } let Some(path) = packaged_link_candidates(link, Some(package)).into_iter().find(|p| crate::io::is_file(p)) else { continue }; - std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned()); + // Another file than the link named, found again by name: the bytes weren't read from it. + std::sync::Arc::make_mut(asset).set_link(Some(path.to_string_lossy().into_owned()), None); } } /// The first candidate for `link` ([`packaged_link_candidates`]) that is a regular file no larger -/// than [`MAX_LINKED_FILE_BYTES`] holding an image or a graphic, with its path. +/// than [`MAX_LINKED_FILE_BYTES`] holding an image or a graphic, with its path and the size and +/// time to keep with its bytes ([`super::links::loaded_fingerprint`]). #[cfg(not(target_arch = "wasm32"))] -fn read_packaged_link(link: &str, package: Option<&std::path::Path>) -> Option<(String, Vec)> { +fn read_packaged_link(link: &str, package: Option<&std::path::Path>) -> Option<(String, Vec, Option)> { for path in packaged_link_candidates(link, package) { - if !crate::io::is_file(&path) || !crate::io::metadata(&path).is_ok_and(|m| m.len() <= MAX_LINKED_FILE_BYTES) { + if !crate::io::is_file(&path) { continue; } + // The version about to be read: one that replaces it during the read (a syncing or + // network file) has other metadata, and the next Links check compares the bytes. + let Ok(metadata) = crate::io::metadata(&path) else { continue }; + if metadata.len() > MAX_LINKED_FILE_BYTES { + continue; + } + let before = super::datamerge::fingerprint_from(&metadata); // At most one byte over the limit, in case the file grew since. let Ok(bytes) = crate::io::read_capped(&path, MAX_LINKED_FILE_BYTES) else { continue }; if bytes.len() as u64 > MAX_LINKED_FILE_BYTES || !is_graphic(&bytes) { continue; } - return Some((path.to_string_lossy().into_owned(), bytes)); + let loaded = super::links::loaded_fingerprint(Some(before), &bytes); + return Some((path.to_string_lossy().into_owned(), bytes, loaded)); } None } @@ -520,6 +532,9 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); let (idml, png) = linked_idml("C:\\original-computer\\project\\Links\\illustrations\\logo.png", false).unwrap(); std::fs::write(&path, &png).unwrap(); + // Not written just now: its size and time can vouch for the imported bytes. + let earlier = std::time::SystemTime::now() - std::time::Duration::from_secs(60); + std::fs::File::options().write(true).open(&path).unwrap().set_modified(earlier).unwrap(); std::fs::write(dir.join("logo.png"), b"a different file with the same basename").unwrap(); let doc = import(&idml, Some(&dir.join("x.idml"))).unwrap(); @@ -527,6 +542,8 @@ mod tests { assert_eq!(*asset.data, png, "the packaged graphic must render even when its author-machine path no longer exists"); let resolved = std::path::absolute(&path).unwrap(); assert_eq!(asset.link.as_deref(), resolved.to_str(), "remember the resolved link so update and preflight use the packaged file"); + let expected = super::super::datamerge::fingerprint_from(&std::fs::metadata(&path).unwrap()); + assert_eq!(asset.link_fingerprint, Some(expected), "remember the same version of the file whose bytes were imported"); assert_eq!(super::super::links::status_cached(asset, None, &mut Default::default()), "ok"); std::fs::remove_dir_all(dir).unwrap(); } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index ec7bf6ba8..22e31cf64 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -13,6 +13,7 @@ use std::path::Path; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use designcraft_compose::Cache; use designcraft_doc::{AssetId, Content, Document, Fingerprint, Item, ItemId, Selection, Story, StoryId, TextFileLink}; @@ -26,11 +27,9 @@ pub fn specs() -> Vec { vec![ cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { // Taken out for the call so the document needn't be cloned on every poll. - let mut checks = std::mem::take(&mut s.text_link_checks); - let mut images = std::mem::take(&mut s.image_link_checks); - let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks, &mut images)); - s.text_link_checks = checks; - s.image_link_checks = images; + let mut checks = std::mem::take(&mut s.link_checks); + let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), Some(&mut checks), None).unwrap_or_default()); + s.link_checks = checks; Ok(Value::Array(links?)) }), cmd!( @@ -77,7 +76,7 @@ pub fn specs() -> Vec { let aid = asset_param(p, "links.embed")?; s.edit(|d, _| { let a = d.assets.get_mut(&aid).ok_or_else(|| bad("links.embed", "no such graphic"))?; - Arc::make_mut(a).link = None; + Arc::make_mut(a).set_link(None, None); Ok(Value::Null) }) } @@ -163,51 +162,81 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool /// describe other bytes. pub(crate) type ImageChecks = std::collections::HashMap<(AssetId, usize, std::path::PathBuf), (Fingerprint, bool)>; +/// The size and time to keep with `bytes` just read from a linked file, `before` being the +/// file's from before the read. Kept only when they can vouch for the bytes later: the size is +/// the bytes' (the file didn't change while it was read) and the time is [`settled`] (another +/// write in the same second, a file still being synced, would keep it). A linked text file's time +/// is kept on the same terms. +pub(crate) fn loaded_fingerprint(before: Option, bytes: &[u8]) -> Option { + before.filter(|f| f.size == bytes.len() as u64 && settled(f)) +} + +/// Is the linked file still the one the asset's bytes were loaded from, going by the size and +/// time kept then ([`designcraft_doc::Asset::link_fingerprint`], [`loaded_fingerprint`])? Nothing +/// is read when it is, also on the first check after a document is opened, which matters on a +/// network share. A fingerprint of another size than the bytes isn't theirs and doesn't count; a +/// new time or no fingerprint leave it to the byte comparison. +#[cfg(not(target_arch = "wasm32"))] +fn loaded_unchanged(a: &designcraft_doc::Asset, now: Fingerprint) -> bool { + a.link_fingerprint.is_some_and(|loaded| loaded.size == a.data.len() as u64 && loaded == now && settled(&now)) +} + /// Link status of a placed graphic (`ok`, `modified`, `missing`, `embedded`), reading the file -/// only when its size is the asset's and its time isn't the one remembered from the last read -/// (`checks`): the Links panel polls twice a second, and a document -/// of press-size images would otherwise be read whole on every poll. A file changed in the last -/// two seconds is never remembered (see [`settled`]). +/// only when its size is the asset's and its time is neither the one the bytes were loaded at +/// ([`loaded_unchanged`]) nor the one remembered from the last read (`checks`): the Links panel +/// polls, and a document of press-size images would otherwise be read whole on every poll. A file +/// changed in the last two seconds is never remembered (see [`settled`]). pub(crate) fn status_cached(a: &designcraft_doc::Asset, document: Option<&Path>, checks: &mut ImageChecks) -> &'static str { - let Some(link) = &a.link else { return "embedded" }; + image_status(a, document, checks).0 +} + +/// [`status_cached`], and the file's size and time when a read found the asset's bytes in it +/// although the fingerprint kept with the asset says nothing or something else: what +/// [`remember_link_files`] keeps, so the file isn't read again in another session. +fn image_status(a: &designcraft_doc::Asset, document: Option<&Path>, checks: &mut ImageChecks) -> (&'static str, Option) { + let Some(link) = &a.link else { return ("embedded", None) }; // Nothing was read from the file (a package's link that wasn't followed): missing, as in // Preflight. if a.data.is_empty() { - return "missing"; + return ("missing", None); } #[cfg(not(target_arch = "wasm32"))] { - let Ok(path) = crate::io::document_path(link, document) else { return "missing" }; + let Ok(path) = crate::io::document_path(link, document) else { return ("missing", None) }; if !crate::io::is_file(&path) { - return "missing"; + return ("missing", None); } let now = match crate::io::metadata(&path) { - Err(_) => return "missing", + Err(_) => return ("missing", None), Ok(m) => super::datamerge::fingerprint_from(&m), }; + if loaded_unchanged(a, now) { + return ("ok", None); + } if now.size != a.data.len() as u64 { - return "modified"; + return ("modified", None); } let key = (a.id, Arc::as_ptr(&a.data) as usize, path.clone()); if let Some((at, same)) = checks.get(&key) && *at == now { - return if *same { "ok" } else { "modified" }; + return if *same { ("ok", Some(now)) } else { ("modified", None) }; } let same = match crate::io::read_capped(&path, a.data.len() as u64) { Ok(b) => b == *a.data, - Err(_) => return "missing", + Err(_) => return ("missing", None), }; // The time from before the read: a change during it is seen next time. - if settled(&now) { + let settled = settled(&now); + if settled { checks.insert(key, (now, same)); } - if same { "ok" } else { "modified" } + if same { ("ok", settled.then_some(now)) } else { ("modified", None) } } #[cfg(target_arch = "wasm32")] { let _ = (link, document, checks); - "ok" + ("ok", None) } } @@ -342,19 +371,130 @@ fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec> { out } -/// `links.list`: every used placed graphic, then every linked text file, each with its status -/// (`images` and `checks` remember what was read this session, so a poll reads nothing unchanged). -fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks, images: &mut ImageChecks) -> Vec { - let texts = d.stories.values().filter_map(|st| { - let l = st.file_link.as_ref()?; +/// What a check of a document's links remembers between runs: the linked text files and images +/// already compared with the document ([`TextChecks`], [`ImageChecks`]), so a poll reads nothing +/// unchanged. The [`Session`] keeps one for `links.list`; a caller that lists a document's links +/// itself ([`list_document_links`]) keeps its own. +#[derive(Default)] +pub struct LinkListChecks { + text: TextChecks, + images: ImageChecks, + /// Linked images the latest list read and found to hold the document's bytes, with the file's + /// size and time then ([`LinkListChecks::take_verified`]). + verified: Vec<(AssetId, Fingerprint)>, +} + +impl LinkListChecks { + /// The linked images the latest list found unchanged by comparing bytes, because the size + /// and time kept with them said nothing or something else: [`remember_link_files`] keeps the + /// file's current ones with them. + pub fn take_verified(&mut self) -> Vec<(AssetId, Fingerprint)> { + std::mem::take(&mut self.verified) + } + + /// Add what `other` remembers (the checks of another worker thread). + pub fn absorb(&mut self, other: LinkListChecks) { + self.text.extend(other.text); + self.images.extend(other.images); + } + + /// How many files are remembered. + pub fn len(&self) -> usize { + self.text.len() + self.images.len() + } + + /// Is nothing remembered yet? + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// The rows of `links.list` without looking at a linked file: `checking` stands where a file +/// would be read or asked for its size and time. A native UI shows these at once and fills in +/// what [`list_document_links`] finds on a worker thread, where a share that doesn't answer +/// can't stall it. +pub fn preview_document_links(d: &Document, cache: &Cache, document: Option<&Path>) -> Vec { + list(d, cache, document, None, None).unwrap_or_default() +} + +/// The rows of `links.list` for a document outside a [`Session`], so a native UI can check the +/// linked files on a worker thread. `checks` is what the worker remembers between calls. Another +/// thread sets `stop` to end the check before its next file (the one being asked may be on a +/// share that doesn't answer, and hundreds more may follow it): nothing is returned then. +pub fn list_document_links( + d: &Document, + cache: &Cache, + document: Option<&Path>, + checks: &mut LinkListChecks, + stop: &AtomicBool, +) -> Option> { + list(d, cache, document, Some(checks), Some(stop)) +} + +/// Keep, with each linked image in `files`, the size and time its file had when a check read it +/// and found the document's bytes in it ([`LinkListChecks::take_verified`]). Later checks, and +/// the first one of another session, then don't read it: copying, restoring or checking out a +/// project folder gives every file a new time. `doc` is the document the check ran on: nothing +/// is kept when the active document isn't that one any more (other bytes may be linked by now), +/// or during a gesture. Nothing placed changes, so a saved document stays saved; the times go +/// into its file with the next save. → how many were kept +pub fn remember_link_files(s: &mut Session, doc: &Arc, files: &[(AssetId, Fingerprint)]) -> usize { + let Ok(st) = s.doc_mut() else { return 0 }; + if !Arc::ptr_eq(&st.doc, doc) || st.interaction.is_some() { + return 0; + } + let stale = |a: &designcraft_doc::Asset, f: &Fingerprint| a.link.is_some() && a.data.len() as u64 == f.size && a.link_fingerprint != Some(*f); + let fresh: Vec<(AssetId, Fingerprint)> = files.iter().filter(|(id, f)| st.doc.assets.get(id).is_some_and(|a| stale(a, f))).copied().collect(); + if fresh.is_empty() { + return 0; + } + let saved = !st.is_dirty(); + let d = Arc::make_mut(&mut st.doc); + for (id, f) in &fresh { + if let Some(a) = d.assets.get_mut(id) { + Arc::make_mut(a).link_fingerprint = Some(*f); + } + } + if saved { + st.saved_doc = Arc::clone(&st.doc); + } + fresh.len() +} + +/// `links.list`: every used placed graphic, then every linked text file, each with its status. +/// `files` is what was read this session (a poll reads nothing unchanged); without it no file is +/// looked at and a status that needs one is `checking` ([`preview_document_links`]). `None` when +/// `stop` was set before the last link was reached. +fn list(d: &Document, cache: &Cache, document: Option<&Path>, files: Option<&mut LinkListChecks>, stop: Option<&AtomicBool>) -> Option> { + let stopped = || stop.is_some_and(|stop| stop.load(Ordering::Relaxed)); + let (mut checks, mut images, mut verified) = match files { + Some(files) => { + files.verified.clear(); + (Some(&mut files.text), Some(&mut files.images), Some(&mut files.verified)) + } + None => (None, None, None), + }; + let texts = d.stories.values().filter_map(|st| Some((st, st.file_link.as_ref()?))).map(|(st, l)| { + if stopped() { + return None; + } let uses: Vec = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect(); - Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l, checks, document), "refused": refused(&l.path, document).map(|r| r.to_string()), "edited": story_hash(st) != l.story_hash, "uses": uses})) + let why = refused(&l.path, document).map(|r| r.to_string()); + let status = match checks.as_deref_mut() { + Some(checks) => text_status(l, checks, document), + None if why.is_some() => "missing", + None => "checking", + }; + Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": status, "refused": why, "edited": story_hash(st) != l.story_hash, "uses": uses})) }); let used = d.used_assets(); d.assets .values() .filter(|a| used.contains(&a.id)) .map(|a| { + if stopped() { + return None; + } let uses: Vec = shown(d, cache, a.id) .into_iter() .map(|u| { @@ -377,7 +517,19 @@ fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextC .collect(); let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name()); let why = a.link.as_deref().and_then(|l| refused(l, document)).map(|r| r.to_string()); - json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status_cached(a, document, images), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses}) + let status = match images.as_deref_mut() { + Some(images) => { + let (status, file) = image_status(a, document, images); + if let (Some(verified), Some(file)) = (verified.as_deref_mut(), file) { + verified.push((a.id, file)); + } + status + } + None if a.link.is_none() => "embedded", + None if a.data.is_empty() || why.is_some() => "missing", + None => "checking", + }; + Some(json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status, "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})) }) .chain(texts) .collect() @@ -456,7 +608,10 @@ fn reload(path: impl AsRef) -> Result<(Vec, (u32, u32))> { /// Relink passes the file the user chose as both. Update passes the path the stored link resolved /// to and the stored link itself, so refreshing a link never rewrites what the document holds. fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, link: &str) -> Result> { + // The size and time from before the read: a change during it is seen by the next check. + let before = super::datamerge::fingerprint_of(read); let (bytes, px) = reload(read)?; + let loaded = loaded_fingerprint(before, &bytes); let page = if designcraft_render::is_pdf(&bytes) { a.page } else { 0 }; let px = if page > 0 { designcraft_render::pdf_page_size(&bytes, page as usize) @@ -471,7 +626,7 @@ fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, link: &str) -> Result a.pixels = Some(px); a.page = page; a.name = Path::new(link).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| link.to_string()); - a.link = Some(link.to_string()); + a.set_link(Some(link.to_string()), loaded); // A placed PDF keeps its crop choice; its box is found on the new file. super::file::crop_pdf(&mut a); Ok(Arc::new(a)) @@ -497,7 +652,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { d.assets.insert(aid, fresh); Ok(json!({"name": name})) }); - s.image_link_checks.clear(); + s.link_checks.images.clear(); r } @@ -518,7 +673,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { } let missing_only = p.get("missingOnly").and_then(Value::as_bool).unwrap_or(dir.is_some() && ext.is_none()); let only: Option> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); - let mut images = std::mem::take(&mut s.image_link_checks); + let mut images = std::mem::take(&mut s.link_checks.images); let st = s.doc()?; let (d, document) = (&st.doc, st.path.as_deref().map(Path::new)); let mut todo: Vec<(AssetId, std::path::PathBuf)> = Vec::new(); @@ -573,7 +728,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { })?; images.clear(); } - s.image_link_checks = images; + s.link_checks.images = images; Ok(json!({"relinked": n, "notFound": not_found})) } @@ -584,8 +739,7 @@ fn update(s: &mut Session, p: &Value) -> Result { (st.doc.clone(), st.path.clone()) }; let document = document.as_deref().map(Path::new); - let checks = &mut s.text_link_checks; - let images = &mut s.image_link_checks; + let LinkListChecks { text: checks, images, .. } = &mut s.link_checks; let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p)); let all = only_asset.is_none() && only_story.is_none(); // A link craft-docpath refuses is missing, so it is never read or refreshed. @@ -626,7 +780,7 @@ fn update(s: &mut Session, p: &Value) -> Result { } Ok(json!({"updated": n, "warnings": warnings})) }); - s.image_link_checks.clear(); + s.link_checks.images.clear(); r } diff --git a/crates/engine/src/cmd/links/tests.rs b/crates/engine/src/cmd/links/tests.rs index c3391b333..733891dbf 100644 --- a/crates/engine/src/cmd/links/tests.rs +++ b/crates/engine/src/cmd/links/tests.rs @@ -76,6 +76,268 @@ fn unchanged_linked_images_are_not_read_again() { let _ = std::fs::remove_dir_all(&dir); } +/// A file's time `secs` ago (a file changed in the last two seconds is always read). +#[cfg(not(target_arch = "wasm32"))] +fn set_age(path: &Path, secs: u64) { + let f = std::fs::File::options().write(true).open(path).unwrap(); + f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(secs)).unwrap(); +} + +/// The size and time an image was loaded at are kept with it, so a file that still has them is +/// not read at all, also on the first check after a document is opened (nothing is remembered +/// yet then, and reading every link of a document on a network share takes minutes). The bytes +/// here differ from the file's on purpose: a status that read the file would say modified. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_linked_image_at_its_loaded_size_and_time_is_not_read() { + let dir = std::env::temp_dir().join(format!("dc-image-link-loaded-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("photo.bin"); + std::fs::write(&path, b"file").unwrap(); + set_age(&path, 60); + let loaded = crate::cmd::datamerge::fingerprint_of(&path).unwrap(); + let asset = |link_fingerprint| designcraft_doc::Asset { + id: AssetId(1), + link: Some(path.to_string_lossy().into_owned()), + link_fingerprint, + data: Arc::new(b"data".to_vec()), + ..Default::default() + }; + let status = |a: &designcraft_doc::Asset| status_cached(a, None, &mut ImageChecks::default()); + + assert_eq!(status(&asset(Some(loaded))), "ok", "the size and time it was loaded at: not read"); + // No fingerprint (a document from before they were kept) or another time: compared. + assert_eq!(status(&asset(None)), "modified"); + assert_eq!(status(&asset(Some(Fingerprint { mtime: loaded.mtime - 60, ..loaded }))), "modified", "a new time alone is compared"); + // Bytes replaced without their fingerprint: it is of another size, so it isn't theirs. + let swapped = designcraft_doc::Asset { data: Arc::new(b"other bytes".to_vec()), ..asset(Some(loaded)) }; + assert_eq!(status(&swapped), "modified"); + // A time too recent to trust: another write in the same second would keep it. + set_age(&path, 0); + let recent = crate::cmd::datamerge::fingerprint_of(&path).unwrap(); + assert_eq!(status(&asset(Some(recent))), "modified"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A file saved again unchanged, or copied, restored from a backup or checked out again, has a +/// new time and the same bytes: it stays OK (only a new size is conclusive), read once per new +/// time. The fingerprint is saved with the document, so every link of a copied project folder +/// would otherwise show Modified. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_graphic_rewritten_with_the_same_bytes_stays_ok() { + let dir = std::env::temp_dir().join(format!("dc-link-touched-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("logo.png"); + let image = png(2, 2); + let rewrite = |bytes: &[u8], age: u64| { + std::fs::write(&path, bytes).unwrap(); + set_age(&path, age); + }; + rewrite(&image, 300); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap()); + rewrite(&image, 60); + let a = s.doc().unwrap().doc.assets[&aid].clone(); + assert!(a.link_fingerprint.is_some()); + assert_ne!(a.link_fingerprint, crate::cmd::datamerge::fingerprint_of(&path), "the file has another time than when it was placed"); + let mut checks = ImageChecks::default(); + assert_eq!(status_cached(&a, None, &mut checks), "ok"); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok"); + + // One byte changed, same size, at a time not seen before: compared again. + let mut changed = image.clone(); + if let Some(last) = changed.last_mut() { + *last ^= 0xff; + } + rewrite(&changed, 120); + assert_eq!(status_cached(&a, None, &mut checks), "modified"); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "modified"); + // Update remembers the file it read, so the next check needs no read. + assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); + let a = s.doc().unwrap().doc.assets[&aid].clone(); + assert_eq!(a.link_fingerprint, crate::cmd::datamerge::fingerprint_of(&path)); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The rows a UI shows while the files are checked on a worker thread come from the document +/// alone: no file is looked at, not even for its size, so a share that doesn't answer can't +/// hold them back. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn preview_rows_look_at_no_file() { + let dir = std::env::temp_dir().join(format!("dc-link-preview-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (linked, embedded, gone) = (dir.join("linked.png"), dir.join("embedded.png"), dir.join("gone.png")); + for (path, width) in [(&linked, 10), (&embedded, 11), (&gone, 12)] { + std::fs::write(path, png(width, 10)).unwrap(); + set_age(path, 60); + } + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let place = |s: &mut Session, path: &Path, x: u32| { + AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap()["asset"].as_u64().unwrap()) + }; + let ids = [place(&mut s, &linked, 72), place(&mut s, &embedded, 144), place(&mut s, &gone, 216)]; + s.execute("links.embed", &json!({"asset": ids[1].0})).unwrap(); + std::fs::remove_file(&gone).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.assets[&ids[0]].link_fingerprint.is_some(), "a placed file is remembered by its size and time"); + assert!(st.doc.assets[&ids[1]].link_fingerprint.is_none(), "embed forgets the file"); + let statuses = |rows: &[Value]| -> Vec { + ids.iter().map(|id| rows.iter().find(|r| r["asset"] == id.0).unwrap()["status"].as_str().unwrap().to_string()).collect() + }; + + let (preview, seen) = crate::io::touched(|| preview_document_links(&st.doc, &s.cache, None)); + assert!(seen.is_empty(), "the preview looked at {seen:?}"); + assert_eq!(statuses(&preview), ["checking", "embedded", "checking"]); + + // The check fills them in, with the rest of each row as in the preview. + let checked = list_document_links(&st.doc, &s.cache, None, &mut LinkListChecks::default(), &AtomicBool::new(false)).unwrap(); + assert_eq!(statuses(&checked), ["ok", "embedded", "missing"]); + let without_status = |rows: &[Value]| -> Vec { + rows.iter() + .cloned() + .map(|mut row| { + if let Some(row) = row.as_object_mut() { + row.remove("status"); + } + row + }) + .collect() + }; + assert_eq!(without_status(&preview), without_status(&checked)); + assert_eq!(Value::Array(checked), s.execute("links.list", &json!({})).unwrap(), "the same rows as links.list"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The size and time kept with bytes read from a linked file vouch for them later only if the +/// file wasn't being written then: a file changed in the same second, or still being synced, +/// keeps its time and size, and a status trusting them would say OK for other bytes. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_file_written_just_now_is_not_vouched_for_by_its_time() { + let dir = std::env::temp_dir().join(format!("dc-link-settled-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("photo.png"); + let image = png(3, 3); + std::fs::write(&path, &image).unwrap(); + let now = crate::cmd::datamerge::fingerprint_of(&path).unwrap(); + assert_eq!(loaded_fingerprint(Some(now), &image), None, "written in the last two seconds"); + set_age(&path, 60); + let settled = crate::cmd::datamerge::fingerprint_of(&path).unwrap(); + assert_eq!(loaded_fingerprint(Some(settled), &image), Some(settled)); + assert_eq!(loaded_fingerprint(Some(settled), &image[1..]), None, "the file changed while it was read"); + assert_eq!(loaded_fingerprint(None, &image), None); + + // Placed while it is that recent, then replaced by other bytes of the same size at the same + // time: the status reads the file instead of trusting the time. + std::fs::write(&path, &image).unwrap(); + let written = std::fs::metadata(&path).unwrap().modified().unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.assets[&aid].link_fingerprint, None); + let mut other = image.clone(); + if let Some(last) = other.last_mut() { + *last ^= 0xff; + } + std::fs::write(&path, &other).unwrap(); + std::fs::File::options().write(true).open(&path).unwrap().set_modified(written).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "modified"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A check on a worker thread is told to stop when its document is no longer the one shown: it +/// ends before the next file instead of asking a share that doesn't answer about hundreds more. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_stopped_check_looks_at_no_further_file() { + let dir = std::env::temp_dir().join(format!("dc-link-stop-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (name, x) in [("a.png", 72), ("b.png", 144)] { + let path = dir.join(name); + std::fs::write(&path, png(x, 10)).unwrap(); + s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap(); + } + let st = s.doc().unwrap(); + let mut checks = LinkListChecks::default(); + + let stop = AtomicBool::new(true); + let (rows, seen) = crate::io::touched(|| list_document_links(&st.doc, &s.cache, None, &mut checks, &stop)); + assert_eq!(rows, None); + assert!(seen.is_empty(), "a stopped check looked at {seen:?}"); + + stop.store(false, Ordering::Relaxed); + assert_eq!(list_document_links(&st.doc, &s.cache, None, &mut checks, &stop).map(|rows| rows.len()), Some(2)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A copied or restored project folder gives every linked file a new time. The check then +/// compares the bytes, and what it found is kept with the document, so neither later checks nor +/// the next session read the file again. The document isn't marked as changed by that. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_file_found_unchanged_by_its_bytes_is_remembered_with_the_document() { + let dir = std::env::temp_dir().join(format!("dc-link-remember-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("photo.png"); + std::fs::write(&path, png(4, 4)).unwrap(); + set_age(&path, 300); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap()); + s.execute("file.saveAs", &json!({"path": dir.join("doc.designcraft").to_string_lossy()})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + set_age(&path, 60); + let file = crate::cmd::datamerge::fingerprint_of(&path).unwrap(); + let check = |s: &Session, checks: &mut LinkListChecks| { + let st = s.doc().unwrap(); + let rows = list_document_links(&st.doc, &s.cache, st.path.as_deref().map(Path::new), checks, &AtomicBool::new(false)).unwrap(); + (rows[0]["status"].as_str().unwrap().to_string(), checks.take_verified()) + }; + + // Read once and found unchanged; found again from what the check remembers. + let mut checks = LinkListChecks::default(); + assert_eq!(check(&s, &mut checks), ("ok".to_string(), vec![(aid, file)])); + assert_eq!(checks.len(), 1); + assert_eq!(check(&s, &mut checks), ("ok".to_string(), vec![(aid, file)])); + + // Not for another document state, another size, or a graphic that isn't linked. + let checked = s.doc().unwrap().doc.clone(); + let other = Arc::new((*checked).clone()); + assert_eq!(remember_link_files(&mut s, &other, &[(aid, file)]), 0); + assert_eq!(remember_link_files(&mut s, &checked, &[(aid, Fingerprint { size: file.size + 1, ..file })]), 0); + assert_eq!(remember_link_files(&mut s, &checked, &[(AssetId(u64::MAX), file)]), 0); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &checked), "nothing kept, nothing changed"); + + assert_eq!(remember_link_files(&mut s, &checked, &[(aid, file)]), 1); + let st = s.doc().unwrap(); + assert_eq!(st.doc.assets[&aid].link_fingerprint, Some(file)); + assert!(!st.is_dirty(), "a saved document stays saved"); + let kept = s.doc().unwrap().doc.clone(); + assert_eq!(remember_link_files(&mut s, &kept, &[(aid, file)]), 0, "already kept"); + // The next check, with nothing remembered (another session), reads nothing and has nothing new. + assert_eq!(check(&s, &mut LinkListChecks::default()), ("ok".to_string(), vec![])); + + // What one worker remembers goes to the next. + let mut pooled = LinkListChecks::default(); + assert!(pooled.is_empty()); + pooled.absorb(checks); + assert_eq!(pooled.len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + /// A linked file's size is the document's choice, so a link is read under a cap (#1596). #[cfg(not(target_arch = "wasm32"))] #[test] diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 85b06a92d..8d706a2b3 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -310,10 +310,14 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset let path = dir.join(&name); root.write(&path, a.data.as_slice())?; let mut na = (**a).clone(); - na.link = Some(match relative_to { + let link = match relative_to { Some(folder) => format!("{folder}/{name}"), None => path.to_string_lossy().to_string(), - }); + }; + // The copy is the linked file now, and the original's size and time say nothing of it. + // Its own are too recent to vouch for it yet: the first check compares the bytes and + // keeps them (`remember_link_files`). + na.set_link(Some(link), None); out.assets.insert(id, Arc::new(na)); written.push(path); } diff --git a/crates/engine/src/cmd/package/tests.rs b/crates/engine/src/cmd/package/tests.rs index 0ffc7979b..1848d5b5d 100644 --- a/crates/engine/src/cmd/package/tests.rs +++ b/crates/engine/src/cmd/package/tests.rs @@ -303,9 +303,14 @@ fn package_writes_document_links_and_report() { std::fs::create_dir_all(&dir).unwrap(); let svg = dir.join("logo.svg"); std::fs::write(&svg, r##""##).unwrap(); + // An older file than the copies made of it below. + let earlier = std::time::SystemTime::now() - std::time::Duration::from_secs(60); + std::fs::File::options().write(true).open(&svg).unwrap().set_modified(earlier).unwrap(); + let fingerprint = |path: &std::path::Path| crate::cmd::datamerge::fingerprint_of(path); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": svg.to_string_lossy(), "x": 100, "y": 100})).unwrap(); + assert_eq!(s.doc().unwrap().doc.assets.values().next().unwrap().link_fingerprint, fingerprint(&svg)); s.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Hello"})).unwrap(); let out = dir.join("Pkg"); let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": true, "instructions": "Print on matte."})).unwrap(); @@ -323,10 +328,16 @@ fn package_writes_document_links_and_report() { let packed = super::super::from_bytes(&std::fs::read(doc_file).unwrap()).unwrap(); let link = packed.assets.values().next().unwrap().link.clone().unwrap(); assert!(link.contains("Links"), "{link}"); + // The link is the copy now: the original file's size and time don't go with it (the copy's + // own are too recent to vouch for it; the first check compares the bytes and keeps them). + assert!(fingerprint(&out.join("Links").join("logo.svg")).is_some()); + assert_eq!(packed.assets.values().next().unwrap().link_fingerprint, None); // Copy Links To relinks the open document. let r = s.execute("links.copyTo", &json!({"dir": dir.join("Copies").to_string_lossy()})).unwrap(); assert_eq!(r["copied"], 1); - assert!(s.doc().unwrap().doc.assets.values().next().unwrap().link.as_deref().unwrap().contains("Copies")); + let relinked = s.doc().unwrap().doc.assets.values().next().unwrap().clone(); + assert!(relinked.link.as_deref().unwrap().contains("Copies")); + assert_eq!(relinked.link_fingerprint, None); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index e2bf23e9a..87479c214 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -53,9 +53,9 @@ pub fn specs() -> Vec { } let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let mut replacements = Vec::new(); - unvar(st, i, n, &value, 0, &mut |pos, len| replacements.push((pos, len))); - for (pos, tlen) in replacements { - d.adjust_text_ranges(sid, pos..pos + tlen, value.len()); + unvar(st, i, n, &value, 0, &mut |pos, len, ins| replacements.push((pos, len, ins))); + for (pos, tlen, ins) in replacements { + d.adjust_text_ranges(sid, pos..pos + tlen, ins); } } d.text_variables.remove(i); @@ -356,21 +356,30 @@ fn needs_unvar(st: &designcraft_doc::Story, i: usize, depth: usize) -> bool { /// `i + 1..n` down by one, including table cells and foot/endnotes nested in the story. Only /// touches (and unshares) the parts that hold such instances; a story whose nested text changed /// gets a new revision. -fn unvar(st: &mut designcraft_doc::Story, i: usize, n: usize, value: &str, depth: usize, on_replace: &mut dyn FnMut(usize, usize)) { +fn unvar(st: &mut designcraft_doc::Story, i: usize, n: usize, value: &str, depth: usize, on_replace: &mut dyn FnMut(usize, usize, usize)) { if depth > MAX_DEPTH { return; } if st.text.chars().any(|c| designcraft_doc::vars::var_index(c).is_some_and(|k| k >= i)) { + // The instances as they are now, replaced last first so earlier positions stay valid: + // text put in isn't searched again (a value holding the marker would never end). + let instances = |text: &str, target: &str| { + let mut at: Vec = text.match_indices(target).map(|(pos, _)| pos).collect(); + at.reverse(); + at + }; let target = var_char(i).unwrap_or(' ').to_string(); - let mut occurrences: Vec = st.text.match_indices(&target).map(|(pos, _)| pos).collect(); - occurrences.reverse(); - for pos in occurrences { - st.replace(pos..pos + target.len(), value); - on_replace(pos, target.len()); + let at = instances(&st.text, &target); + if !at.is_empty() { + let value = var_char(i).map_or_else(|| value.to_string(), |c| value.replace(c, "")); + for pos in at { + st.replace(pos..pos + target.len(), &value); + on_replace(pos, target.len(), value.len()); + } } for k in i + 1..n { let (from, to) = (var_char(k).unwrap_or(' ').to_string(), var_char(k - 1).unwrap_or(' ').to_string()); - while let Some(pos) = st.text.find(&from) { + for pos in instances(&st.text, &from) { st.replace(pos..pos + from.len(), &to); } } @@ -382,14 +391,14 @@ fn unvar(st: &mut designcraft_doc::Story, i: usize, n: usize, value: &str, depth } for c in &mut std::sync::Arc::make_mut(t).cells { if needs_unvar(&c.text, i, depth + 1) { - unvar(&mut c.text, i, n, value, depth + 1, &mut |_, _| {}); + unvar(&mut c.text, i, n, value, depth + 1, &mut |_, _, _| {}); } } nested = true; } for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { if needs_unvar(¬e.text, i, depth + 1) { - unvar(&mut std::sync::Arc::make_mut(note).text, i, n, value, depth + 1, &mut |_, _| {}); + unvar(&mut std::sync::Arc::make_mut(note).text, i, n, value, depth + 1, &mut |_, _, _| {}); nested = true; } } diff --git a/crates/engine/src/cmd/variables/tests.rs b/crates/engine/src/cmd/variables/tests.rs index 6a2291ccb..726f8d6a8 100644 --- a/crates/engine/src/cmd/variables/tests.rs +++ b/crates/engine/src/cmd/variables/tests.rs @@ -290,6 +290,30 @@ fn delete_variable_reaches_table_cells() { assert_eq!(t.cells[0].text.text, format!("Alpha|{}", var_char(nb).unwrap())); } +/// A custom variable whose text holds its own instance marker: deleting it puts that text in +/// once per instance, without the value's own marker, and ends (the text put in isn't searched +/// for instances again). +#[test] +fn delete_variable_whose_value_holds_its_marker_ends() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": "a"})).unwrap(); + let a = s.doc().unwrap().doc.variable("A").unwrap(); + let marker = var_char(a).unwrap(); + s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": format!("<{marker}>")})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": format!("x{marker}y{marker}z")})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + // A link on the last "z": its range follows the text put in (without the marker). + let z = format!("x{marker}y{marker}").len(); + s.execute("text.select", &json!({"story": sid.0, "anchor": z, "focus": z + 1})).unwrap(); + s.execute("hyperlink.create", &json!({"url": "https://example.org"})).unwrap(); + s.execute("variables.delete", &json!({"name": "A"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.stories.values().next().unwrap().text, "x<>y<>z"); + let designcraft_doc::HyperlinkSource::Text { start, end, .. } = &d.hyperlinks[0].source else { panic!("a text link") }; + assert_eq!((*start, *end), (6, 7)); +} + /// Deleting a variable flattens its instances in footnotes and endnotes too and moves the /// instances of later variables down. #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1c585aa4f..f87371531 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -392,6 +392,10 @@ pub struct Session { active: Option, /// An undoable command is running: the commands it runs are part of its undo step. in_undo_step: bool, + /// Scripts running one inside another, and the steps the outermost may still run + /// ([`script::MAX_SCRIPT_DEPTH`], [`script::MAX_SCRIPT_STEPS`]). + pub(crate) script_depth: usize, + pub(crate) script_steps: usize, pub prefs: Prefs, pub cache: Arc, pub(crate) tool: Box, @@ -408,8 +412,10 @@ pub struct Session { pub clipboard_origin: f64, /// Crash-recovery folder (the app sets it; saving or closing a document removes its entry). pub recovery_dir: Option, - /// Copied text with its formatting (a frameless story slice) and its plain text. - pub text_clipboard: Option<(Arc, String)>, + /// Copied text with its formatting (a frameless story slice), its plain text, and what pasting + /// it needs from its document: a copy holding only the stories its anchored text frames show + /// (and theirs) and the graphics the text and those stories place. + pub text_clipboard: Option<(Arc, String, Arc)>, /// Requests for the UI (dialogs, view changes) produced by tools/commands. pub ui_requests: Vec, /// Graphic loaded in the place cursor. @@ -435,13 +441,10 @@ pub struct Session { /// New Document's Preview: the preview document's uid and the uid of the document that was /// active before it (see [`Session::preview_new_document`]). pub(crate) new_document_preview: Option<(u64, Option)>, - /// Linked text files checked by the Links panel: path → the size and modification time the - /// file had and the hash of its contents then, so a file touched but not changed is read once. - pub(crate) text_link_checks: std::collections::HashMap, - /// Linked images checked against their files this session (`links.list` polls twice a second): - /// a file of the recorded size and time isn't read again. - pub(crate) image_link_checks: - std::collections::HashMap<(designcraft_doc::AssetId, usize, std::path::PathBuf), (designcraft_doc::Fingerprint, bool)>, + /// Linked text files and images checked against their files this session (`links.list` is + /// polled): a file of the recorded size and time isn't read again, and one touched but not + /// changed is read once. + pub(crate) link_checks: cmd::LinkListChecks, } impl Default for Session { @@ -462,6 +465,8 @@ impl Session { docs: vec![], active: None, in_undo_step: false, + script_depth: 0, + script_steps: 0, prefs: Prefs::default(), cache: Arc::new(Cache::new()), tool: designcraft_tools::create("selection"), @@ -473,8 +478,7 @@ impl Session { clipboard_origin: 0.0, recovery_dir: None, text_clipboard: None, - text_link_checks: Default::default(), - image_link_checks: Default::default(), + link_checks: Default::default(), ui_requests: vec![], loaded: None, transforms: Default::default(), @@ -555,11 +559,18 @@ impl Session { self.active = Some(i); } } - pub fn add_document(&mut self, d: DocState) -> usize { + pub fn add_document(&mut self, mut d: DocState) -> usize { if let Err(e) = self.end_composition() { log::warn!("ending the IME composition: {e}"); } self.reset_find_scope(); + // A built document (a data merge, say) may hold copies of anchored text frames. + if d.doc.has_anchored_items() { + let mut doc = (*d.doc).clone(); + if doc.fix_anchored_text_frames() > 0 { + d.doc = Arc::new(doc); + } + } self.stop_data_preview(); self.docs.push(d); self.active = Some(self.docs.len() - 1); @@ -806,7 +817,14 @@ impl Session { let r = f(&mut doc, &mut sel)?; crate::cmd::sync_placeholders(&mut doc); crate::cmd::flex::sync(&mut doc); + // The generated endnote story first: it replaces its text, anchored frames pasted into it + // included. doc.sync_endnote_story(); + // Text and frames copied or deleted: anchored items keep ids and stories of their own. The + // repair copies and removes stories with their endnotes, so the endnotes follow it. + if doc.anchored_items_changed_since(&st.doc) && doc.fix_anchored_text_frames() > 0 { + doc.sync_endnote_story(); + } st.doc = Arc::new(doc); st.selection = sel; st.bump_revision(); diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index 2f8394f34..1ccd6f29c 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -75,8 +75,8 @@ fn step_of(v: &Value) -> Result { Ok(Step { command, params }) } -/// The value a reference (`N.path`, `last.path`) points at. -fn lookup(r: &str, results: &[Value]) -> Result { +/// The value a reference (`N.path`, `last.path`) points at, borrowed from `results`. +fn lookup<'a>(r: &str, results: &'a [Value]) -> Result<&'a Value, String> { let mut parts = r.split('.'); let head = parts.next().unwrap_or(""); let idx = if head == "last" { @@ -84,14 +84,14 @@ fn lookup(r: &str, results: &[Value]) -> Result { } else { head.parse::().map_err(|_| format!("bad reference `${r}`"))? }; - let mut v = results.get(idx).ok_or_else(|| format!("`${r}`: step {idx} hasn't run (only {} have)", results.len()))?.clone(); + let mut v = results.get(idx).ok_or_else(|| format!("`${r}`: step {idx} hasn't run (only {} have)", results.len()))?; for p in parts { - v = match (&v, p.parse::()) { - (Value::Array(a), Ok(i)) => a.get(i).cloned(), - (Value::Object(o), _) => o.get(p).cloned(), + v = match (v, p.parse::()) { + (Value::Array(a), Ok(i)) => a.get(i), + (Value::Object(o), _) => o.get(p), _ => None, } - .ok_or_else(|| format!("`${r}`: no `{p}` in {}", short(&v)))?; + .ok_or_else(|| format!("`${r}`: no `{p}` in {}", short(v)))?; } Ok(v) } @@ -110,14 +110,59 @@ fn is_reference(r: &str) -> bool { head_ok && parts.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')) } +/// Longest string a `${…}` interpolation may build: a step that pastes an earlier result into a +/// story, then interpolates that story into the next, could otherwise double its size each step. +pub const MAX_INTERPOLATED_BYTES: usize = 16 << 20; + +fn too_long(s: &str) -> String { + // Cut at a char boundary: a preview of a long Unicode input mustn't copy all of it. + let preview = &s[..s.floor_char_boundary(40)]; + format!("the interpolated text of {preview} is longer than {MAX_INTERPOLATED_BYTES} bytes").replace('\n', " ") +} + +/// Longest string a whole reference (`$last.text`) may copy in. A story is far smaller; the cap +/// stops a loop of "read the story, paste it back" from doubling it until memory runs out. +pub const MAX_REFERENCED_BYTES: usize = 64 << 20; + +/// Most bytes of strings one step's parameters may hold once its references are resolved: the +/// limits above apply to each string, this one to a whole parameter tree (an array of 256 +/// references to a large string would pass each of them). +pub const MAX_STEP_PARAM_BYTES: usize = 256 << 20; + +/// Roughly how many bytes cloning `v` allocates (its strings, plus a word per node). +fn weight(v: &Value) -> usize { + match v { + Value::String(s) => s.len(), + Value::Array(a) => a.iter().fold(8, |n, x| n.saturating_add(weight(x))), + Value::Object(o) => o.iter().fold(8, |n, (k, x)| n.saturating_add(k.len()).saturating_add(weight(x))), + _ => 8, + } +} + +fn charge(budget: &mut usize, bytes: usize) -> Result<(), String> { + *budget = + budget.checked_sub(bytes).ok_or_else(|| format!("the parameters of a step are longer than {MAX_STEP_PARAM_BYTES} bytes once resolved"))?; + Ok(()) +} + /// Replace references in `params` with values from earlier `results`. pub fn resolve(params: &Value, results: &[Value]) -> Result { + resolve_in(params, results, MAX_REFERENCED_BYTES, &mut { MAX_STEP_PARAM_BYTES }) +} + +fn resolve_in(params: &Value, results: &[Value], max_ref: usize, budget: &mut usize) -> Result { Ok(match params { Value::String(s) => { if let Some(r) = s.strip_prefix('$').filter(|r| is_reference(r)) { - return lookup(r, results); + let v = lookup(r, results)?; + if matches!(v, Value::String(t) if t.len() > max_ref) { + return Err(format!("`${r}` is longer than {max_ref} bytes")); + } + charge(budget, weight(v))?; + return Ok(v.clone()); } if !s.contains("${") && !s.contains("$$") { + charge(budget, s.len())?; return Ok(params.clone()); } let mut out = String::new(); @@ -130,21 +175,32 @@ pub fn resolve(params: &Value, results: &[Value]) -> Result { rest = tail; } else if let Some(inner) = after.strip_prefix('{') { let end = inner.find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))?; - match lookup(&inner[..end], results)? { - Value::String(t) => out.push_str(&t), - other => out.push_str(&other.to_string()), + // The length is checked before the value is copied in. + let piece = match lookup(&inner[..end], results)? { + Value::String(t) => std::borrow::Cow::Borrowed(t.as_str()), + other => std::borrow::Cow::Owned(other.to_string()), + }; + if out.len().saturating_add(piece.len()) > MAX_INTERPOLATED_BYTES { + return Err(too_long(s)); } + out.push_str(&piece); rest = &inner[end + 1..]; } else { out.push('$'); rest = after; } } + if out.len().saturating_add(rest.len()) > MAX_INTERPOLATED_BYTES { + return Err(too_long(s)); + } out.push_str(rest); + charge(budget, out.len())?; Value::String(out) } - Value::Array(a) => Value::Array(a.iter().map(|v| resolve(v, results)).collect::>()?), - Value::Object(o) => Value::Object(o.iter().map(|(k, v)| Ok((k.clone(), resolve(v, results)?))).collect::>()?), + Value::Array(a) => Value::Array(a.iter().map(|v| resolve_in(v, results, max_ref, budget)).collect::>()?), + Value::Object(o) => { + Value::Object(o.iter().map(|(k, v)| Ok((k.clone(), resolve_in(v, results, max_ref, budget)?))).collect::>()?) + } v => v.clone(), }) } @@ -182,17 +238,58 @@ pub fn run(steps: &[Step], mut exec: impl FnMut(&str, Value) -> Result Report { - run(steps, |c, p| self.execute(c, &p).map_err(|e| e.to_string())) + if self.script_depth >= MAX_SCRIPT_DEPTH { + let command = steps.first().map(|s| s.command.clone()).unwrap_or_default(); + return Report { + results: vec![], + failed: Some((0, command, format!("scripts nest deeper than {MAX_SCRIPT_DEPTH} (a script running a script…)"))), + }; + } + if self.script_depth == 0 { + self.script_steps = MAX_SCRIPT_STEPS; + } + self.script_depth += 1; + let report = run(steps, |c, p| { + self.script_steps = + self.script_steps.checked_sub(1).ok_or_else(|| format!("the script and the scripts it runs exceed {MAX_SCRIPT_STEPS} steps"))?; + self.execute(c, &p).map_err(|e| e.to_string()) + }); + self.script_depth -= 1; + report } } #[cfg(test)] mod tests { + #[test] + fn the_limit_error_previews_a_multibyte_input_in_bounds() { + let e = super::too_long(&"€".repeat(1000)); + assert!(e.len() < 150, "{e}"); + } + use super::*; + /// A whole reference to a long string is refused, and the strings of one step's parameters + /// share a budget (small limits here: the real ones would mean allocating them). + #[test] + fn whole_references_and_the_parameter_tree_are_bounded() { + let results = vec![json!({"text": "x".repeat(100)})]; + assert!(resolve_in(&json!("$0.text"), &results, 100, &mut 1000).is_ok()); + assert!(resolve_in(&json!("$0.text"), &results, 99, &mut 1000).unwrap_err().contains("longer than 99")); + let many = json!(["$0.text", "$0.text", "${0.text}"]); + assert!(resolve_in(&many, &results, 100, &mut 300).is_ok()); + assert!(resolve_in(&many, &results, 100, &mut 299).unwrap_err().contains("once resolved")); + } + #[test] fn leading_blank_lines_keep_line_numbers() { let e = parse("\n\n# c\nfile.new\nframe.create {bad").unwrap_err(); @@ -275,6 +372,35 @@ mod tests { assert_eq!(r.results[3]["text"], "a\u{a0}v\u{a0}Praze"); } + /// A script that runs scripts stops at the depth limit, and the steps of all of them count + /// against one budget; the session goes on afterwards. + #[test] + fn nested_scripts_are_bounded() { + let mut s = crate::Session::new(); + let steps = parse("file.new\ndocument.inspect\ndocument.inspect").unwrap(); + s.script_depth = MAX_SCRIPT_DEPTH; + let e = s.run_script(&steps).failed.expect("must fail").2; + assert!(e.contains("nest deeper"), "{e}"); + s.script_depth = 1; + s.script_steps = 2; + let r = s.run_script(&steps); + assert_eq!(r.results.len(), 2); + assert!(r.failed.as_ref().is_some_and(|f| f.2.contains("exceed")), "{r:?}"); + s.script_depth = 0; + let r = s.run_script(&steps); + assert!(r.failed.is_none(), "{r:?}"); + assert_eq!(s.script_depth, 0); + } + + #[test] + fn interpolation_is_capped() { + let big = "x".repeat(MAX_INTERPOLATED_BYTES / 2 + 1); + let results = vec![json!({"text": big})]; + assert!(resolve(&json!("${0.text}"), &results).is_ok()); + let e = resolve(&json!("${0.text}${0.text}"), &results).unwrap_err(); + assert!(e.contains("longer than"), "{e}"); + } + #[test] fn runs_on_a_session_with_references() { let mut s = crate::Session::new(); diff --git a/crates/engine/tests/support/optional_content.rs b/crates/engine/tests/support/optional_content.rs index b3acb3511..2b506b5f4 100644 --- a/crates/engine/tests/support/optional_content.rs +++ b/crates/engine/tests/support/optional_content.rs @@ -95,6 +95,7 @@ pub fn document(bytes: Vec, hidden_sets: &[&[&str]]) -> Document { mime: "application/pdf".into(), data: Arc::new(bytes), link: None, + link_fingerprint: None, pixels: None, page: 0, pdf_crop: Default::default(), diff --git a/crates/epub/tests/it/grouped_reading_order.rs b/crates/epub/tests/it/grouped_reading_order.rs index 42811f61d..c4689cf20 100644 --- a/crates/epub/tests/it/grouped_reading_order.rs +++ b/crates/epub/tests/it/grouped_reading_order.rs @@ -90,6 +90,7 @@ fn nested_and_item_rotations_are_combined_before_measuring_graphics() { name: "Grouped image".into(), mime: "image/svg+xml".into(), link: None, + link_fingerprint: None, data: Arc::new(br#""#.to_vec()), pixels: None, page: 0, diff --git a/crates/images/src/eps.rs b/crates/images/src/eps.rs index 33c8e1e38..bd2498f1a 100644 --- a/crates/images/src/eps.rs +++ b/crates/images/src/eps.rs @@ -423,6 +423,29 @@ mod tests { assert!(!is_eps(b"%!PS-Adobe-3.0\nnot encapsulated")); } + #[test] + fn dos_eps_wmf_only_is_not_read_as_tiff() { + let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\nnewpath\n%%EOF\n"; + let wmf = b"WMFDATA!"; + let mut dos = vec![0xC5, 0xD0, 0xD3, 0xC6]; + let ps_off = 30usize; + let wmf_off = ps_off + eps.len(); + for v in [ps_off, eps.len(), wmf_off, wmf.len(), 0, 0] { + dos.extend((v as u32).to_le_bytes()); + } + dos.extend([0xFF, 0xFF]); + dos.extend_from_slice(eps); + dos.extend_from_slice(wmf); + assert!(is_eps(&dos)); + // The WMF bytes are not a TIFF preview. + assert!(sections(&dos).1.is_none()); + // The PostScript section still parses, and the proxy is the no-preview placeholder. + assert_eq!(bounding_box(&dos), Some([0.0, 0.0, 100.0, 50.0])); + let (png, size) = eps_proxy(&dos).unwrap(); + assert_eq!(size, (100.0, 50.0)); + assert_eq!(crate::pixel_size(&png), Some((200, 100))); + } + #[test] fn eps_bounding_box_rejects_nonfinite_sizes() { let bb = |line: &str| bounding_box(format!("%!PS-Adobe-3.0 EPSF-3.0\n{line}\n").as_bytes()); diff --git a/crates/render/tests/it/colorize.rs b/crates/render/tests/it/colorize.rs index a99cc8435..b88854e8e 100644 --- a/crates/render/tests/it/colorize.rs +++ b/crates/render/tests/it/colorize.rs @@ -40,6 +40,7 @@ fn shown(file: Vec, fill: Fill) -> Vec<[u8; 4]> { name: "a".into(), mime: "image/png".into(), link: None, + link_fingerprint: None, data: Arc::new(file), pixels: Some((3, 1)), pdf_crop: Default::default(), diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 0131885ae..40a494679 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1771,6 +1771,7 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { name: "photo.png".into(), mime: "image/png".into(), link: None, + link_fingerprint: None, data: std::sync::Arc::new(vec![]), pixels: Some((100, 100)), page: 0, diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b2b433b71..88bd48c58 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -53,6 +53,8 @@ impl DesignApp { views: HashMap::new(), page_history: HashMap::new(), canvas: CanvasCache::new(), + #[cfg(not(target_arch = "wasm32"))] + links_worker: crate::links_worker::State::default(), canvas_rect: None, split: false, second_window: false, diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 3c90d8c59..069515394 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -194,8 +194,9 @@ impl DesignApp { } /// [`Self::open_dropped_at`] for one file of a drop. `frame_free` is whether the frame under the - /// pointer can still be filled: the first graphic of a drop takes it (and clears the flag), so - /// the files after it land at the pointer instead of replacing that graphic. + /// pointer can still be filled: the first graphic of a drop takes it, or a frame of its own at + /// the pointer when there is none, and clears the flag, so the files after it land at the + /// pointer instead of replacing that graphic. pub(crate) fn place_dropped(&mut self, path: &str, pointer: Option, frame_free: &mut bool) -> Result { if path.to_ascii_lowercase().ends_with(".ase") { return self.run("swatch.load", json!({"path": path})); @@ -210,7 +211,8 @@ impl DesignApp { let mut params = json!({"path": path}); // A graphic dropped on an empty or graphic frame fills it, as File > Place does. Text keeps // getting a frame of its own, so it never replaces the image in a graphic frame. - let frame = if *frame_free && !designcraft_textimport::is_text_file(path) { canvas::drop_frame(self, pointer) } else { None }; + let is_text = designcraft_textimport::is_text_file(path); + let frame = if *frame_free && !is_text { canvas::drop_frame(self, pointer) } else { None }; if let (Some(frame), Some(o)) = (frame, params.as_object_mut()) { o.insert("frame".into(), json!(frame.0)); } @@ -224,7 +226,9 @@ impl DesignApp { } } let placed = self.open_file("file.place", params); - if frame.is_some() && placed.is_ok() { + // A graphic placed in a frame of its own (dropped on a line or on the page) is under the + // pointer too, so the next file of the drop must not replace it (#2290). + if placed.is_ok() && !is_text { *frame_free = false; } placed diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f544f5bf5..4ce86d083 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -272,7 +272,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { crate::separations::paint(app, ui.ctx(), &painter, &xf, &layout, screen); let hair = 1.0 / ui.ctx().pixels_per_point(); for slot in &layout.slots { - painter.rect_stroke(xf.rect(slot.bounds), 0.0, Stroke::new(hair, Color32::from_rgb(0x4a, 0x4a, 0x4a)), StrokeKind::Outside); + painter.rect_stroke(xf.rect(slot.bounds), 0.0, Stroke::new(hair, page_decor::PAGE_EDGE), StrokeKind::Outside); } if !preview { let drag = ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))); diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 5b7f38d5f..590b0f8c6 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -171,15 +171,16 @@ fn item_at(app: &DesignApp, p: Point, zoom: f64) -> Option<(designcraft_doc::Ite /// The frame under a pointer over the canvas that takes a graphic (empty, holding a graphic, or /// holding only graphics pasted into it): a graphic dropped there fills it instead of becoming a -/// new object. `None` when the pointer isn't over the canvas or over such a frame, and for a -/// locked frame, which a drop never replaces. +/// new object. `None` when the pointer isn't over the canvas or over such a frame, for a locked +/// frame, which a drop never replaces, and for a line or any path that encloses no area, which +/// would become a zero-height image frame (#2290), as the place cursor skips them. pub fn drop_frame(app: &DesignApp, screen: Option) -> Option { let rect = app.canvas_rect?; let pos = screen.filter(|p| rect.contains(*p))?; let v = app.view()?; let (id, _) = item_at(app, Xf::new(rect, v).to_canvas(pos), v.zoom)?; let it = app.session.active()?.doc.item(id)?; - (!it.locked && it.takes_graphic()).then_some(id) + (!it.locked && it.path.encloses_area() && it.takes_graphic()).then_some(id) } /// Wheel, pinch and Alt/Ctrl/Cmd + wheel over the canvas: zoom at the pointer, else scroll. diff --git a/crates/ui-egui/src/canvas/page_decor.rs b/crates/ui-egui/src/canvas/page_decor.rs index af2bec79b..39df15a78 100644 --- a/crates/ui-egui/src/canvas/page_decor.rs +++ b/crates/ui-egui/src/canvas/page_decor.rs @@ -10,6 +10,10 @@ pub(super) fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, painter.extend(egui::Shape::dashed_line(&[a, b], stroke, dash, gap)); } +/// The hairline around each spread and at its spine, where its pages meet: one dark grey on any +/// paper colour and in either theme, so the binding edge reads like the spread's own edge. +pub(super) const PAGE_EDGE: Color32 = Color32::from_rgb(0x4a, 0x4a, 0x4a); + /// The most baseline grid lines drawn on a page: one per point of the tallest page (216 in). pub(super) const MAX_BASELINES: usize = 216 * 72; @@ -52,6 +56,7 @@ pub(super) fn draw_guides( let Some(sp) = doc.spread(slot.spread) else { continue }; if app.ui.guides { draw_bleed_and_slug(painter, xf, doc, slot, sp); + draw_spine(painter, xf, slot, sp); } let pasteboard = slot.xf.inverse().transform_rect_bbox(layout.pasteboard); for (pi, p) in sp.pages.iter().enumerate() { @@ -81,6 +86,16 @@ fn draw_bleed_and_slug(painter: &egui::Painter, xf: &Xf, doc: &Document, slot: & } } +/// The spine: a hairline at each edge where two pages of a spread meet +/// ([`designcraft_doc::Spread::page_seams`]), in the spread edge's colour ([`PAGE_EDGE`]). Screen +/// only, like the other guides; the paper is one rectangle and exports never show a seam. +fn draw_spine(painter: &egui::Painter, xf: &Xf, slot: &SpreadSlot, sp: &designcraft_doc::Spread) { + let pt = |p: Point| xf.to_screen(slot.xf * p); + for (x, y0, y1) in sp.page_seams() { + painter.line_segment([pt(Point::new(x, y0)), pt(Point::new(x, y1))], Stroke::new(hair(painter), PAGE_EDGE)); + } +} + /// Page tool › Show Parent Page Overlay: the parent page's size over the page. fn draw_parent_overlay(painter: &egui::Painter, xf: &Xf, doc: &Document, slot: &SpreadSlot, pi: usize, p: &designcraft_doc::Page, pr: DRect) { let rect = |r: DRect| xf.rect(slot.xf.transform_rect_bbox(r)); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index b4d621b09..62064ea79 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -71,6 +71,102 @@ fn a_press_after_a_drag_is_not_a_double_click() { assert_eq!(press(Some(up), 0.0, 1.0), 1); } +/// The shapes the canvas paints for a new document, after two frames (the first lays out). +fn canvas_shapes(app: &mut DesignApp) -> Vec { + let ctx = egui::Context::default(); + let mut shapes = Vec::new(); + for _ in 0..2 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + shapes = out.shapes; + } + shapes +} + +/// The spine-coloured vertical hairlines at screen x `x` that run the full height of `page` +/// (the spine of one spread shares its x with the pages of the spreads above and below it). +fn vertical_lines_at(shapes: &[egui::epaint::ClippedShape], x: f32, page: Rect) -> usize { + let spine = page_decor::PAGE_EDGE; + shapes + .iter() + .filter(|c| match &c.shape { + egui::Shape::LineSegment { points: [a, b], stroke } => { + stroke.color == spine + && (a.x - x).abs() < 0.01 + && (b.x - x).abs() < 0.01 + && a.y.min(b.y) <= page.min.y + 0.01 + && a.y.max(b.y) >= page.max.y - 0.01 + } + _ => false, + }) + .count() +} + +#[test] +fn a_spread_shows_a_spine_line_between_its_pages() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"pages": 3, "facingPages": true})).unwrap(); + let shapes = canvas_shapes(&mut app); + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, false); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + // One spine at each edge two pages of a spread share, the full page height, and none + // at the outer edges of a spread or around a page on a spread of its own. + let mut pairs = 0; + for (si, sp) in st.doc.spreads.iter().enumerate() { + let first = st.doc.first_page_of_spread(si); + let rects: Vec = (0..sp.pages.len()).map(|pi| xf.rect(layout.page_rect(&st.doc, first + pi).unwrap())).collect(); + for w in rects.windows(2) { + assert!((w[0].max.x - w[1].min.x).abs() < 0.01, "pages of a spread meet"); + assert_eq!(vertical_lines_at(&shapes, w[0].max.x, w[0]), 1, "spine in spread {si}"); + pairs += 1; + } + let (left, right) = (rects[0], rects[rects.len() - 1]); + assert_eq!(vertical_lines_at(&shapes, left.min.x, left), 0, "outer left edge of spread {si}"); + assert_eq!(vertical_lines_at(&shapes, right.max.x, right), 0, "outer right edge of spread {si}"); + } + assert!(pairs > 0, "the document has a spread with two pages"); +} + +#[test] +fn the_spine_sits_where_the_pages_of_a_right_to_left_spread_meet() { + // Bound right to left, the spread lists its right page first. With pages of different + // sizes, the line must be at their shared edge, not halfway between the outer ones, and + // run the height of the taller page, not stop at the shorter one listed first. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"pages": 3, "facingPages": true})).unwrap(); + app.run("layout.documentSetup", serde_json::json!({"binding": "rightToLeft"})).unwrap(); + let settings = &app.session.active().unwrap().doc.settings; + let (w, h) = (settings.page_width, settings.page_height); + app.run("layout.pageSize", serde_json::json!({"pages": [2], "width": w * 0.6, "height": h * 0.6})).unwrap(); + let shapes = canvas_shapes(&mut app); + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, false); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let (right, left) = (xf.rect(layout.page_rect(&st.doc, 1).unwrap()), xf.rect(layout.page_rect(&st.doc, 2).unwrap())); + assert!((left.max.x - right.min.x).abs() < 0.01, "page 3 is the left page, page 2 the right"); + assert!((left.width() - right.width()).abs() > 1.0, "the pages differ in width"); + assert!(left.height() - right.height() > 1.0, "page 3 is the taller page"); + assert_eq!(vertical_lines_at(&shapes, left.max.x, left), 1); +} + +#[test] +fn the_spine_line_hides_with_the_other_guides() { + for (guides, mode) in [(false, ScreenMode::Normal), (true, ScreenMode::Preview), (true, ScreenMode::Presentation)] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"pages": 3, "facingPages": true})).unwrap(); + app.ui.guides = guides; + app.ui.screen_mode = mode; + let shapes = canvas_shapes(&mut app); + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, false); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let left = xf.rect(layout.page_rect(&st.doc, 1).unwrap()); + assert_eq!(vertical_lines_at(&shapes, left.max.x, left), 0, "guides={guides} mode={mode:?}"); + } +} + /// While a page's render is on its way the page shows paper, not the black of its shadow. #[test] fn pages_show_paper_under_their_render() { diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 7c7d31e88..adf22f4fb 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1057,6 +1057,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set", "مجموعة"), ("unused", "غير مستخدم"), ("⚠ Missing", "⚠ مفقود"), + ("Checking", "جارٍ التحقق"), + ("Link status is unavailable.", "حالة الروابط غير متاحة."), ("▲ Modified", "▲ معدّل"), ("(Text Color)", "(لون النص)"), ("{count} glyphs — click to insert at the text cursor", "عدد المحارف: {count} — انقر للإدراج عند مؤشر النص"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index b648aef56..5310e8d12 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1499,6 +1499,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set", "Sada"), ("unused", "nepoužito"), ("⚠ Missing", "⚠ Chybí"), + ("Checking", "Kontroluje se"), + ("Link status is unavailable.", "Stav propojení není k dispozici."), ("▲ Modified", "▲ Změněno"), ("(Text Color)", "(Barva textu)"), ("{count} glyphs — click to insert at the text cursor", "Glyfy: {count} — kliknutím vložíte na pozici textového kurzoru"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 894af427a..2e9920126 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -1501,6 +1501,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set", "Festlegen"), ("unused", "nicht verwendet"), ("⚠ Missing", "⚠ Fehlt"), + ("Checking", "Wird geprüft"), + ("Link status is unavailable.", "Der Verknüpfungsstatus ist nicht verfügbar."), ("▲ Modified", "▲ Geändert"), ("(Text Color)", "(Textfarbe)"), ("{count} glyphs — click to insert at the text cursor", "{count} Glyphen – zum Einfügen an der Einfügemarke klicken"), diff --git a/crates/ui-egui/src/i18n/hu.rs b/crates/ui-egui/src/i18n/hu.rs index b8d2942c2..ab30bce8d 100644 --- a/crates/ui-egui/src/i18n/hu.rs +++ b/crates/ui-egui/src/i18n/hu.rs @@ -1939,6 +1939,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("⌥-click clears overrides", "⌥-kattintás törli a felülírásokat"), ("▲ Modified", "▲ Módosítva"), ("⚠ Missing", "⚠ Hiányzik"), + ("Checking", "Ellenőrzés"), + ("Link status is unavailable.", "A csatolások állapota nem érhető el."), ("⚠ out of date", "⚠ elavult"), ("Selection Tool", "Kijelölő eszköz"), ("Direct Selection Tool", "Közvetlen kijelölő eszköz"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 28d58bf1a..131875d46 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1679,6 +1679,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set", "Imposta"), ("unused", "non utilizzato"), ("⚠ Missing", "⚠ Mancante"), + ("Checking", "Verifica in corso"), + ("Link status is unavailable.", "Lo stato dei collegamenti non è disponibile."), ("▲ Modified", "▲ Modificato"), ("(Text Color)", "(Colore testo)"), ("{count} glyphs — click to insert at the text cursor", "{count} glifi — fai clic per inserire nella posizione del cursore di testo"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 2164f1309..ae2dd653f 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1702,6 +1702,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set", "セット"), ("unused", "未使用"), ("⚠ Missing", "⚠ リンク切れ"), + ("Checking", "確認中"), + ("Link status is unavailable.", "リンクの状態を取得できません。"), ("▲ Modified", "▲ 変更済み"), ("(Text Color)", "(テキストカラー)"), ("{count} glyphs — click to insert at the text cursor", "{count}グリフ — クリックでテキストカーソル位置に挿入"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 949e8e12c..1ba9a61a9 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1594,6 +1594,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set", "დაყენება"), ("unused", "გამოუყენებელი"), ("⚠ Missing", "⚠ აკლია"), + ("Checking", "მოწმდება"), + ("Link status is unavailable.", "ბმულის სტატუსი მიუწვდომელია."), ("▲ Modified", "▲ შეცვლილია"), ("(Text Color)", "(ტექსტის ფერი)"), ("{count} glyphs — click to insert at the text cursor", "გლიფი: {count} — დაწკაპეთ ტექსტის კურსორთან ჩასასმელად"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 16d8e7d3a..7aac5d74b 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1534,6 +1534,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set", "Ustaw"), ("unused", "nieużywane"), ("⚠ Missing", "⚠ Brak"), + ("Checking", "Sprawdzanie"), + ("Link status is unavailable.", "Stan łączy jest niedostępny."), ("▲ Modified", "▲ Zmodyfikowane"), ("(Text Color)", "(Kolor tekstu)"), ("{count} glyphs — click to insert at the text cursor", "Glify: {count} — kliknij, aby wstawić przy kursorze tekstowym"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 65cbf07c3..147947dbd 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -103,7 +103,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added swatch", "Amostra adicionada"), ("Eyedropper: drag it onto the page", "Conta-gotas: arraste-o para a página"), ("PDF Preset:", "Predefinição de PDF:"), - ("Packaged {count} files into {dir}", "{count} arquivos empacotados em {dir}"), + ("Packaged {count} files into {dir}", "{count} arquivo(s) empacotado(s) em {dir}"), ("Continue", "Continuar"), ("Printing Instructions", "Instruções de impressão"), ("Package", "Empacotar"), @@ -409,7 +409,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Library", "Biblioteca"), ("Text Wrap", "Quebra de texto"), ("Align", "Alinhar"), - ("Pathfinder", "Localizador de caminhos"), + ("Pathfinder", "Pathfinder"), ("Transform Again", "Transformar novamente"), ("Interactive", "Interativo"), ("Buttons and Forms", "Botões e formulários"), @@ -811,7 +811,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Minus Back", "Subtrair plano de trás"), ("Make Compound", "Criar composto"), ("Release", "Liberar"), - ("Paths", "Caminhos"), + ("Paths", "Demarcadores"), ("Align left edges", "Alinhar bordas esquerdas"), ("Align horizontal centers", "Alinhar centros horizontais"), ("Align right edges", "Alinhar bordas direitas"), @@ -1061,6 +1061,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set", "Definir"), ("unused", "não utilizado"), ("⚠ Missing", "⚠ Ausente"), + ("Checking", "Verificando"), + ("Link status is unavailable.", "O status dos links não está disponível."), ("▲ Modified", "▲ Modificado"), ("(Text Color)", "(Cor do texto)"), ("{count} glyphs — click to insert at the text cursor", "{count} glifos — clique para inserir no cursor do texto"), @@ -1293,7 +1295,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Pages:", "Páginas:"), ("Part of the ArtCraft suite of open creative tools.", "Parte do conjunto ArtCraft de ferramentas criativas abertas."), ("Pasteboard Options", "Opções da área de transferência"), - ("Path", "Caminho"), + ("Path", "Demarcador"), ("Place the insertion point in a table, or create one.", "Coloque o ponto de inserção em uma tabela ou crie uma."), ("Plain Text", "Texto simples"), ("Plays in EPUB export; print and PDF show the poster.", "Reproduz na exportação EPUB; impressão e PDF mostram o pôster."), @@ -1625,7 +1627,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Character Formatting", "Formatação de caracteres"), ("Clear Conveyor", "Limpar transportador"), ("Clear Transformations", "Limpar transformações"), - ("Clipping Path…", "Traçado de recorte…"), + ("Clipping Path…", "Demarcador de recorte…"), ("Close Book", "Fechar livro"), ("Close Library", "Fechar biblioteca"), ("Collect", "Coletar"), @@ -1724,7 +1726,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("List Styles", "Listar estilos"), ("List Tools", "Listar ferramentas"), ("Load Place Cursor", "Carregar cursor de inserção"), - ("Make Compound Path", "Criar traçado composto"), + ("Make Compound Path", "Criar demarcador composto"), ("Map Styles to Tags", "Mapear estilos para tags"), ("Math Expression SVG", "SVG de expressão matemática"), ("Media Options", "Opções de mídia"), @@ -1783,7 +1785,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Recover Documents", "Recuperar documentos"), ("Recovery Data", "Dados de recuperação"), ("Reject All Changes", "Rejeitar todas as alterações"), - ("Release Compound Path", "Liberar traçado composto"), + ("Release Compound Path", "Liberar demarcador composto"), ("Remove Document", "Remover documento"), ("Report an Issue…", "Relatar um problema…"), ("Reset Shortcuts", "Redefinir atalhos"), @@ -1823,7 +1825,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show/Hide Text Threads", "Mostrar/ocultar encadeamentos de texto"), ("Sort", "Ordenar"), ("Split Note", "Dividir nota"), - ("Split Path", "Dividir traçado"), + ("Split Path", "Dividir demarcador"), ("Step and Repeat…", "Passo e repetição…"), ("Stroke Styles", "Estilos de traço"), ("Style Source", "Origem do estilo"), @@ -1841,8 +1843,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Transform Values", "Valores de transformação"), ("Transparency Blend Space", "Espaço de mesclagem de transparência"), ("Transparency Flattener Presets", "Predefinições de achatamento de transparência"), - ("Type on a Path", "Texto em um traçado"), - ("Type on a Path Options…", "Opções de texto em um traçado…"), + ("Type on a Path", "Texto no demarcador"), + ("Type on a Path Options…", "Opções de texto no demarcador…"), ("Typical Display", "Exibição típica"), ("Update Link", "Atualizar link"), ("User Dictionary Words", "Palavras do dicionário do usuário"), @@ -1873,7 +1875,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert to Liquid Guide", "Converter em guia líquida"), ("Convert to Object", "Converter em objeto"), ("Create Line", "Criar linha"), - ("Create Path", "Criar traçado"), + ("Create Path", "Criar demarcador"), ("Decrease Point Size", "Diminuir tamanho em pontos"), ("Delete Article", "Excluir artigo"), ("Delete Bookmark", "Excluir marcador"), @@ -1943,7 +1945,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper", "Aplicar atributos (conta-gotas"), ("Captions", "Legendas"), ("Change All (Object", "Alterar tudo (objeto"), - ("Clipping Path", "Traçado de recorte"), + ("Clipping Path", "Demarcador de recorte"), ("Convert Cell Type", "Converter tipo de célula"), ("Copy Link(s", "Copiar link(s"), ("DesignCraft", "DesignCraft"), @@ -1991,4 +1993,1046 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert field", "Inserir campo"), ("Pass csv, rows, or bytes from the control channel.", "Passe csv, linhas ou bytes pelo canal de controle."), ("Panels", "Painéis"), + // Strings added to the interface since the catalog was completed (2026-10-15). + (" Save ", " Salvar "), + (" Update ", " Atualizar "), + ("% of all commits", "% de todos os commits"), + ("(any)", "(qualquer)"), + ("(unchanged)", "(inalterado)"), + ("1 Parent Character", "1 caractere base"), + ("1 Ruby Character", "1 caractere ruby"), + ("1 Ruby Character Aki", "Aki de 1 caractere ruby"), + ("1-2-1 (JIS) Rule", "Regra 1-2-1 (JIS)"), + ("1-2-1 Aki", "Aki 1-2-1"), + ("1/2 Parent Character", "1/2 caractere base"), + ("1/2 Ruby Character", "1/2 caractere ruby"), + ("90° CCW", "90° anti-horário"), + ("90° CW", "90° horário"), + ("A, B, C, D...", "A, B, C, D..."), + ("About", "Sobre"), + ("Above/Right", "Acima/direita"), + ("Absorb Ideographic Space at Line End", "Absorver espaço ideográfico no fim da linha"), + ("Across the Line:", "Perpendicular à linha:"), + ("Activate Panel", "Ativar painel"), + ("Add Anchor Point Tool", "Ferramenta Adicionar ponto de ancoragem"), + ("Add Tab", "Adicionar tabulação"), + ("Adjust Rotation", "Ajustar rotação"), + ("Adjust Tsume", "Ajustar tsume"), + ("Adjust page elements to document changes", "Ajustar os elementos da página às alterações do documento"), + ("Adjustment When Ruby Is Longer Than Parent", "Ajuste quando o ruby é mais longo que a base"), + ("Align Left Edge", "Alinhar borda esquerda"), + ("Align Only First Line to Grid", "Alinhar somente a primeira linha à grade"), + ("Align To", "Alinhar a"), + ("All Classes", "Todas as classes"), + ("All pages", "Todas as páginas"), + ("Along the Line:", "Ao longo da linha:"), + ("Alpha Channel", "Canal alfa"), + ("Alternate layout", "Layout alternativo"), + ("Anchored Object Options", "Opções de objeto ancorado"), + ("Apply Numbering & Section Options", "Aplicar opções de numeração e de seção"), + ("Apply Text Attributes (Eyedropper)", "Aplicar atributos de texto (conta-gotas)"), + ("Apply To:", "Aplicar a:"), + ( + "Apply the changes; objects follow the new page size and margins", + "Aplicar as alterações; os objetos acompanham o novo tamanho de página e as margens", + ), + ("Apply to:", "Aplicar a:"), + ("ArtCraft App Page", "Página do aplicativo ArtCraft"), + ("ArtCraft Website", "Site do ArtCraft"), + ("Auto Align at Line Start/End", "Alinhar automaticamente no início/fim da linha"), + ("Auto Rename", "Renomear automaticamente"), + ("Auto Tate-Chu-Yoko", "Tate-chu-yoko automático"), + ("Auto Tate-chu-yoko Settings", "Configurações de tate-chu-yoko automático"), + ("Auto-Size", "Dimensionamento automático"), + ("Auto-Sizing", "Dimensionamento automático"), + ("Automatic Chapter Numbering", "Numeração automática de capítulos"), + ("Automatic Page Numbering", "Numeração automática de páginas"), + ("Below/Left", "Abaixo/esquerda"), + ("Between Entry and Number:", "Entre a entrada e o número:"), + ("Binary assets added", "Arquivos binários adicionados"), + ("Binary assets removed", "Arquivos binários removidos"), + ("Black Circle", "Círculo preto"), + ("Black Triangle", "Triângulo preto"), + ("Blank Document Presets", "Predefinições de documento em branco"), + ("Bleed:", "Sangria:"), + ("Book Name:", "Nome do livro:"), + ("Both Sides", "Ambos os lados"), + ("Both pages of the parent spread", "Ambas as páginas da página dupla principal"), + ("Both pages of the spread", "Ambas as páginas da página dupla"), + ("Bounding Box (All Layers)", "Caixa delimitadora (todas as camadas)"), + ("Bounding Box (Visible Layers Only)", "Caixa delimitadora (somente camadas visíveis)"), + ("Break Thread", "Romper encadeamento"), + ("Bullet Character", "Caractere de marcador"), + ("Bullseye", "Alvo"), + ("Bunri-Kinshi", "Bunri-kinshi"), + ("CMYK:", "CMYK:"), + ("Cancel Preview", "Cancelar visualização"), + ("Cell Number:", "Número do celular:"), + ("Centered", "Centralizado"), + ("Change Panel Layout", "Alterar layout do painel"), + ("Change Tab", "Alterar tabulação"), + ("Change fill to:", "Alterar preenchimento para:"), + ("Character Style", "Estilo de caractere"), + ("Character:", "Caractere:"), + ("Characters After Break:", "Caracteres depois da quebra:"), + ("Characters Before Break:", "Caracteres antes da quebra:"), + ("Characters:", "Caracteres:"), + ("Check Spelling", "Verificar ortografia"), + ("Chinese: Simplified", "Chinês: simplificado"), + ("Chinese: Traditional", "Chinês: tradicional"), + ("Clear All Tabs", "Limpar todas as tabulações"), + ("Clear Object Style Overrides", "Limpar substituições de estilo de objeto"), + ("Clear Rotation", "Limpar rotação"), + ("Clipping Path Options…", "Opções de demarcador de recorte…"), + ("Close Panel", "Fechar painel"), + ("Closed Path", "Demarcador fechado"), + ( + "Collect objects with the Content Collector (B); place them with the Content Placer.", + "Colete objetos com o Coletor de conteúdo (B); insira-os com o Posicionador de conteúdo.", + ), + ("Color Blindness – Deuteranopia", "Daltonismo – deuteranopia"), + ("Color Blindness – Protanopia", "Daltonismo – protanopia"), + ("Color Theme", "Tema de cores"), + ("Color Theme Tool", "Ferramenta Tema de cores"), + ("Color Theme…", "Tema de cores…"), + ("Color font", "Fonte colorida"), + ("Column Break", "Quebra de coluna"), + ("Column Gutter", "Medianiz da coluna"), + ("Commit Preview", "Confirmar visualização"), + ("Commits", "Commits"), + ("Company", "Empresa"), + ("Conditional Text", "Texto condicional"), + ("Content Collector Tool", "Ferramenta Coletor de conteúdo"), + ("Content Placer Tool", "Ferramenta Posicionador de conteúdo"), + ("Contributors", "Colaboradores"), + ("Convert Direction Point Tool", "Ferramenta Converter ponto de direção"), + ("Convert Rows", "Converter linhas"), + ("Copyright Symbol", "Símbolo de copyright"), + ("Corner Options", "Opções de canto"), + ("Corner Size", "Tamanho do canto"), + ("Couldn't write recovery data: {error}", "Não foi possível gravar os dados de recuperação: {error}"), + ("Create Guides", "Criar guias"), + ("Create Links When Placing Text Files", "Criar vínculos ao inserir arquivos de texto"), + ("Currently Rendered With", "Renderizado atualmente com"), + ("Dark", "Escuro"), + ("Data Fields", "Campos de dados"), + ("Data Merge Options", "Opções de mesclagem de dados"), + ("Decorative", "Decorativa"), + ("Default Rules", "Regras padrão"), + ("Define Cross-Reference Format", "Definir formato de referência cruzada"), + ("Define Lists", "Definir listas"), + ("Define Text Variable", "Definir variável de texto"), + ("Delete Anchor Point Tool", "Ferramenta Excluir ponto de ancoragem"), + ("Delete Character Style", "Excluir estilo de caractere"), + ("Delete Character Style…", "Excluir estilo de caractere…"), + ("Delete Document Preset", "Excluir predefinição de documento"), + ("Delete Paragraph Style…", "Excluir estilo de parágrafo…"), + ("Delete Preset", "Excluir predefinição"), + ("Delete Type from Path", "Excluir texto do demarcador"), + ("Delete the character style \"{name}\"?", "Excluir o estilo de caractere \"{name}\"?"), + ("Delete the paragraph style \"{name}\"?", "Excluir o estilo de parágrafo \"{name}\"?"), + ("DesignCraft Website", "Site do DesignCraft"), + ("DesignCraft on GitHub", "DesignCraft no GitHub"), + ("DesignCraft page", "Página do DesignCraft"), + ("Detect Edges", "Detectar bordas"), + ("Differs from this document", "Difere deste documento"), + ("Digits:", "Dígitos:"), + ("Direct Selection Tool", "Ferramenta Seleção direta"), + ("Discretionary Hyphen", "Hífen opcional"), + ("Display name", "Nome de exibição"), + ("Distance from Parent:", "Distância da base:"), + ("Distribute Rows Evenly", "Distribuir linhas uniformemente"), + ("Document CMYK", "CMYK do documento"), + ("Document Chapter Numbering", "Numeração de capítulos do documento"), + ("Document Endnote Options", "Opções de notas de fim do documento"), + ("Document Footnote Options", "Opções de notas de rodapé do documento"), + ("Document Name", "Nome do documento"), + ("Document RGB", "RGB do documento"), + ("Don't Save", "Não salvar"), + ("Double Circle", "Círculo duplo"), + ("Double Left Quotation Marks", "Aspas duplas de abertura"), + ("Double Right Quotation Marks", "Aspas duplas de fechamento"), + ("Drag Rows or Columns", "Arrastar linhas ou colunas"), + ("Drop Caps", "Capitulares"), + ("Drop Chars", "Caracteres capitulares"), + ("Drop Lines", "Linhas capitulares"), + ("Edit/Interface Language", "Editar/Idioma da interface"), + ("Edit/Spelling", "Editar/Ortografia"), + ("Edit/Transparency Blend Space", "Editar/Espaço de mesclagem de transparência"), + ("Edit/Transparency Flattener Presets", "Editar/Predefinições de achatamento de transparência"), + ("Ellipse Frame Tool", "Ferramenta Quadro elíptico"), + ("Ellipse Tool", "Ferramenta Elipse"), + ("Ellipsis", "Reticências"), + ("Em Box Bottom/Left", "Base/esquerda do quadratim"), + ("Em Box Center", "Centro do quadratim"), + ("Em Box Center (Down)", "Centro do quadratim (abaixo)"), + ("Em Box Top/Right", "Topo/direita do quadratim"), + ("Em Dash", "Travessão"), + ("Email Address:", "Endereço de e-mail:"), + ("En Dash", "Meia-risca"), + ("Entry Style:", "Estilo da entrada:"), + ("Equal Aki", "Aki igual"), + ( + "Equal fixed heights for the selected rows; cell text may become overset.", + "Alturas fixas iguais para as linhas selecionadas; o texto das células pode ficar excedente.", + ), + ("Erase Tool", "Ferramenta Apagar"), + ("Even Page Break", "Quebra para página par"), + ("Export EPUB", "Exportar EPUB"), + ("Export EPUB (Fixed Layout)", "Exportar EPUB (layout fixo)"), + ("Export EPUB (Reflowable)", "Exportar EPUB (reflowable)"), + ("Export HTML", "Exportar HTML"), + ("Export IDML", "Exportar IDML"), + ("Export PDF (Interactive)", "Exportar PDF (interativo)"), + ("Export Page as PNG", "Exportar página como PNG"), + ("Export PowerPoint…", "Exportar PowerPoint…"), + ("Export Text", "Exportar texto"), + ("Export XML", "Exportar XML"), + ( + "Export a PDF, IDML or package to share — no cloud account needed.", + "Exporte um PDF, IDML ou pacote para compartilhar — sem precisar de conta na nuvem.", + ), + ("Extract Color Theme", "Extrair tema de cores"), + ("Eyedropper Tool", "Ferramenta Conta-gotas"), + ("Figure Space", "Espaço de algarismo"), + ("File/Export", "Arquivo/Exportar"), + ("Filters:", "Filtros:"), + ("Find fill:", "Localizar preenchimento:"), + ("First commit", "Primeiro commit"), + ("Fisheye", "Olho de peixe"), + ("Fit to Width", "Ajustar à largura"), + ("Flush Space", "Espaço de alinhamento"), + ("Force", "Forçar"), + ("Forced Line Break", "Quebra de linha forçada"), + ("Formatting Affects Text", "A formatação afeta o texto"), + ("Found", "Encontrado"), + ("Frame", "Quadro"), + ("Frame Break", "Quebra de quadro"), + ("Free Transform Tool", "Ferramenta Transformação livre"), + ("Full Justify", "Justificado total"), + ("GREP", "GREP"), + ("Gap Tool", "Ferramenta Espaço"), + ("Generate Index", "Gerar índice remissivo"), + ("Generate Live Caption", "Gerar legenda dinâmica"), + ("Georgian", "Georgiano"), + ("Get Cell Stroke", "Obter traçado da célula"), + ("Get Tabs", "Obter tabulações"), + ("Global Light", "Luz global"), + ("Grab bag", "Lista corrida"), + ("Gradient Feather Tool", "Ferramenta Esmaecimento em gradiente"), + ("Gradient Swatch Tool", "Ferramenta Amostra de gradiente"), + ("Graphic", "Gráfico"), + ("Grid Alignment:", "Alinhamento à grade:"), + ("Grid Settings", "Configurações de grade"), + ("Group Ruby", "Ruby de grupo"), + ("Group with", "Agrupar com"), + ("Gyoudori:", "Gyoudori:"), + ("Hair Space", "Espaço capilar"), + ("Hand Tool", "Ferramenta Mão"), + ("Hanging Type:", "Pontuação pendente:"), + ("Hard Kinsoku", "Kinsoku rígido"), + ("Hide Styles", "Ocultar estilos"), + ("High Contrast", "Alto contraste"), + ("High Resolution", "Alta resolução"), + ("I, II, III, IV...", "I, II, III, IV..."), + ("ICF Bottom", "Base do ICF"), + ("ICF Top", "Topo do ICF"), + ("Import Warnings", "Avisos de importação"), + ("Import XML", "Importar XML"), + ("Import cancelled: the originating document has closed", "Importação cancelada: o documento de origem foi fechado"), + ("Include Paragraph Styles:", "Incluir estilos de parágrafo:"), + ("Include Prefix when Numbering Pages", "Incluir prefixo ao numerar páginas"), + ("Include Roman Characters", "Incluir caracteres latinos"), + ("Indent to Here", "Recuar até aqui"), + ("Ink Limit 300%", "Limite de tinta 300%"), + ("Insert Anchored Object", "Inserir objeto ancorado"), + ("Insert Cross-Reference", "Inserir referência cruzada"), + ("Insert Field", "Inserir campo"), + ("Insert Math Expression", "Inserir expressão matemática"), + ("Internet Standard RGB (sRGB)", "RGB padrão da Internet (sRGB)"), + ("Invalid panel layout; restored the default dock", "Layout de painéis inválido; o encaixe padrão foi restaurado"), + ("Japanese Composition Settings", "Configurações de composição japonesa"), + ("Join the ArtCraft Discord", "Entrar no Discord do ArtCraft"), + ("Kenten Color", "Cor do kenten"), + ("Kenten Settings", "Configurações de kenten"), + ("Kenten Type:", "Tipo de kenten:"), + ("Kerning/Tracking Step", "Incremento de kerning/espaçamento"), + ("Kerning/Tracking:", "Kerning/espaçamento:"), + ("Key Object", "Objeto-chave"), + ("Kinsoku Set:", "Conjunto kinsoku:"), + ("Kinsoku Type:", "Tipo de kinsoku:"), + ("Korean Kinsoku", "Kinsoku coreano"), + ("Large Sample Text Size", "Tamanho de amostra grande"), + ("Last commit", "Último commit"), + ("Layout/Index", "Layout/Índice remissivo"), + ("Layout/Pages", "Layout/Páginas"), + ("Leading Model:", "Modelo de entrelinha:"), + ("Left/Right Position:", "Posição esquerda/direita:"), + ("Left/Top", "Esquerda/topo"), + ("Legacy Macintosh RGB", "RGB Macintosh antigo"), + ("Light", "Claro"), + ("Line Break Options", "Opções de quebra de linha"), + ("Line Spacing:", "Espaçamento entre linhas:"), + ("Line Tool", "Ferramenta Linha"), + ("Line delta", "Diferença de linhas"), + ("Lines +/−", "Linhas +/−"), + ("Lines added", "Linhas adicionadas"), + ("Lines deleted", "Linhas excluídas"), + ("Lines:", "Linhas:"), + ("List Composite Fonts", "Listar fontes compostas"), + ("List Styles in File", "Listar estilos do arquivo"), + ("Load All Text Styles…", "Carregar todos os estilos de texto…"), + ("Load Character Styles…", "Carregar estilos de caractere…"), + ("Load DTD", "Carregar DTD"), + ("Load Paragraph Styles…", "Carregar estilos de parágrafo…"), + ("Load Styles", "Carregar estilos"), + ("Load Styles…", "Carregar estilos…"), + ("Load Swatches", "Carregar amostras"), + ("Loading fonts…", "Carregando fontes…"), + ("Low Resolution", "Baixa resolução"), + ("Magnification:", "Ampliação:"), + ("Margins:", "Margens:"), + ("Measure Tool", "Ferramenta Medida"), + ("Medium Dark", "Médio-escuro"), + ("Medium Light", "Médio-claro"), + ("Medium Resolution", "Resolução média"), + ("Medium Sample Text Size", "Tamanho de amostra médio"), + ("Menus", "Menus"), + ("Merged PRs", "PRs mesclados"), + ("Message:", "Mensagem:"), + ("Minimum Height", "Altura mínima"), + ("Minimum Scale:", "Escala mínima:"), + ("Minimum Width", "Largura mínima"), + ("Models", "Modelos"), + ("Mojikumi Set:", "Conjunto mojikumi:"), + ("Monitor RGB", "RGB do monitor"), + ("Monospaced", "Monoespaçada"), + ("Move Pages", "Mover páginas"), + ("Move Panel", "Mover painel"), + ("Move Tab", "Mover tabulação"), + ("N/A", "N/D"), + ("Name (A–Z)", "Nome (A–Z)"), + ("New Cell Style", "Novo estilo de célula"), + ("New Condition", "Nova condição"), + ("New Hyperlink", "Novo hiperlink"), + ("New Hyperlink Destination", "Novo destino de hiperlink"), + ("New Page Reference", "Nova referência de página"), + ("New Stroke Style", "Novo estilo de traço"), + ("New Table Style", "Novo estilo de tabela"), + ("Next Page Number", "Número da próxima página"), + ("No Adjustment", "Sem ajuste"), + ("No Line Breaks", "Sem quebras de linha"), + ("No contributor data was built into this copy.", "Nenhum dado de colaboradores foi incluído nesta cópia."), + ("No fonts match", "Nenhuma fonte corresponde"), + ("No matches", "Nenhuma correspondência"), + ("No model credits were built into this copy.", "Nenhum crédito de modelos foi incluído nesta cópia."), + ("Nonbreaking Hyphen", "Hífen inseparável"), + ("Nonbreaking Space", "Espaço inseparável"), + ("None (keep transparency)", "Nenhum (manter transparência)"), + ("Note Tool", "Ferramenta Nota"), + ("Numbering & Section Options", "Opções de numeração e de seção"), + ("Object Style", "Estilo de objeto"), + ("Object Style Options…", "Opções de estilo de objeto…"), + ("Object/Anchored Object", "Objeto/Objeto ancorado"), + ("Object/Arrange", "Objeto/Organizar"), + ("Object/Captions", "Objeto/Legendas"), + ("Object/Clipping Path", "Objeto/Demarcador de recorte"), + ("Object/Content", "Objeto/Conteúdo"), + ("Object/Convert Shape", "Objeto/Converter forma"), + ("Object/Effects", "Objeto/Efeitos"), + ("Object/Fitting", "Objeto/Ajuste"), + ("Object/Pathfinder", "Objeto/Pathfinder"), + ("Object/Paths", "Objeto/Demarcadores"), + ("Object/Select", "Objeto/Selecionar"), + ("Object/Transform", "Objeto/Transformar"), + ("Object/Transform Again", "Objeto/Transformar novamente"), + ("Odd Page Break", "Quebra para página ímpar"), + ("Offset from Parent", "Deslocamento em relação à base"), + ("OpenType font", "Fonte OpenType"), + ("Optical Margin Alignment", "Alinhamento óptico de margem"), + ("Organization:", "Organização:"), + ("Other Styles:", "Outros estilos:"), + ("PDF import cancelled: the originating document has closed", "Importação de PDF cancelada: o documento de origem foi fechado"), + ("Page Break", "Quebra de página"), + ("Page Number:", "Número da página:"), + ("Page Options", "Opções de página"), + ("Page Size:", "Tamanho da página:"), + ("Page Tool", "Ferramenta Página"), + ("Paragraph Return", "Fim de parágrafo"), + ("Paragraph Style", "Estilo de parágrafo"), + ("Paragraph Symbol", "Símbolo de parágrafo"), + ("Parent Character Spacing:", "Espaçamento dos caracteres base:"), + ("Parent's Font", "Fonte da base"), + ("Pen Tool", "Ferramenta Caneta"), + ("Pencil Tool", "Ferramenta Lápis"), + ("Per-Character Ruby", "Ruby por caractere"), + ("Picas", "Paicas"), + ("Points", "Pontos"), + ("Inches", "Polegadas"), + ("Millimeters", "Milímetros"), + ("Centimeters", "Centímetros"), + ("Ciceros", "Cíceros"), + ("Agates", "Ágatas"), + ("Pixels", "Pixels"), + ("Pin Bar Position", "Fixar posição da barra"), + ("Place with Import Options", "Inserir com opções de importação"), + ("Polygon Frame Tool", "Ferramenta Quadro poligonal"), + ("Polygon Tool", "Ferramenta Polígono"), + ("Preflight", "Preflight"), + ("Preflight Panel…", "Painel Preflight…"), + ("Presentation", "Apresentação"), + ("Preset Details", "Detalhes da predefinição"), + ("Preset Name", "Nome da predefinição"), + ("Press keys…", "Pressione as teclas…"), + ("Preview Change", "Visualizar alteração"), + ("Preview Record", "Visualizar registro"), + ("Previous Page Number", "Número da página anterior"), + ("Print Booklet", "Imprimir folheto"), + ("Prioritize Adjustment Amount", "Priorizar a quantidade de ajuste"), + ("Punctuation Space", "Espaço de pontuação"), + ("Push In First", "Primeiro para dentro"), + ("Push Out First", "Primeiro para fora"), + ("Push Out Only", "Somente para fora"), + ("Quarter Space", "Espaço de um quarto"), + ("Quick Apply", "Aplicação rápida"), + ("RGB:", "RGB:"), + ("Range:", "Intervalo:"), + ("Real name", "Nome real"), + ("Recent", "Recentes"), + ("Recent Files", "Arquivos recentes"), + ("Rectangle Frame Tool", "Ferramenta Quadro retangular"), + ("Rectangle Tool", "Ferramenta Retângulo"), + ("Redefine Character Style", "Redefinir estilo de caractere"), + ("Redefine Document Style", "Redefinir estilo do documento"), + ("Redefine Paragraph Style", "Redefinir estilo de parágrafo"), + ("Reference Point", "Ponto de referência"), + ("Registered Trademark Symbol", "Símbolo de marca registrada"), + ("Relink", "Vincular novamente"), + ("Relink to Folder", "Vincular novamente à pasta"), + ("Relinked {count}", "Vinculados novamente: {count}"), + ("Reload Fonts", "Recarregar fontes"), + ("Remove Data Source", "Remover fonte de dados"), + ("Remove Existing Ruler Guides", "Remover guias de régua existentes"), + ("Remove Field", "Remover campo"), + ("Remove Frame from Thread", "Remover quadro do encadeamento"), + ("Remove »", "Remover »"), + ("Rensuuji", "Rensuuji"), + ("Reorder Objects", "Reordenar objetos"), + ("Replace All", "Substituir tudo"), + ( + "Replace All updates this font throughout text and styles. Done keeps remaining fallback fonts.", + "Substituir tudo atualiza esta fonte em todo o texto e estilos. Concluir mantém as fontes substitutas restantes.", + ), + ("Replace Existing Table of Contents", "Substituir sumário existente"), + ("Replace with:", "Substituir por:"), + ("Report an Issue", "Relatar um problema"), + ("Report an issue", "Relatar um problema"), + ("Reset Bar Position", "Redefinir posição da barra"), + ("Resize Row or Column", "Redimensionar linha ou coluna"), + ("Resize panel window", "Redimensionar janela do painel"), + ("Resize panels", "Redimensionar painéis"), + ("Reverse the order", "Inverter a ordem"), + ("Right/Bottom", "Direita/base"), + ("Roman Baseline", "Linha de base latina"), + ("Roman Word Break", "Quebra de palavras latinas"), + ("Rotate Roman Characters in Vertical Text", "Girar caracteres latinos no texto vertical"), + ("Rotate Tool", "Ferramenta Girar"), + ("Ruby Color", "Cor do ruby"), + ("Ruby Font and Size", "Fonte e tamanho do ruby"), + ("Ruby Overhang:", "Projeção do ruby:"), + ("Ruby Placement and Spacing", "Posição e espaçamento do ruby"), + ("Ruby Type:", "Tipo de ruby:"), + ("Ruler Zero Point", "Ponto zero da régua"), + ("Same as Previous Document in the Book", "Igual ao documento anterior do livro"), + ("Sample", "Amostra"), + ("Sans Serif", "Sem serifa"), + ("Save As", "Salvar como"), + ("Save Document Preset", "Salvar predefinição de documento"), + ("Save Document Preset As:", "Salvar predefinição de documento como:"), + ("Save Preset", "Salvar predefinição"), + ("Save Swatches for Exchange", "Salvar amostras para intercâmbio"), + ("Save a Copy", "Salvar uma cópia"), + ("Saved", "Salvas"), + ("Saved Presets", "Predefinições salvas"), + ("Scale Tool", "Ferramenta Escala"), + ("Scale for Descenders", "Escalar para descendentes"), + ("Scissors Tool", "Ferramenta Tesoura"), + ("Script", "Manuscrita"), + ("Scripts", "Scripts"), + ("Section Marker", "Marcador de seção"), + ("Section Marker:", "Marcador de seção:"), + ("Section Prefix:", "Prefixo da seção:"), + ("Section Symbol", "Símbolo de seção"), + ("Select Page", "Selecionar página"), + ("Select Paragraph", "Selecionar parágrafo"), + ("Select Sentence", "Selecionar frase"), + ("Select Story", "Selecionar artigo"), + ("Select Text Unit", "Selecionar unidade de texto"), + ( + "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", + "Selecione um vídeo ou som inserido (Arquivo › Inserir um .mp4, .mov, .mp3, .wav …).", + ), + ("Select an object to color its fill or stroke.", "Selecione um objeto para colorir seu preenchimento ou traçado."), + ("Selected Text", "Texto selecionado"), + ("Selection Tool", "Ferramenta Seleção"), + ("Sent {count} page(s) to {printer}", "{count} página(s) enviada(s) para {printer}"), + ("Sentence case", "Primeira letra da frase em maiúscula"), + ("Serif", "Com serifa"), + ("Sesame Dot", "Ponto de gergelim"), + ("Set Composite Font", "Definir fonte composta"), + ("Set OpenType Features", "Definir recursos OpenType"), + ("Shatai", "Shatai"), + ("Shear Tool", "Ferramenta Distorcer"), + ("Show Recently Added", "Mostrar adicionadas recentemente"), + ("Show Similar Fonts", "Mostrar fontes semelhantes"), + ("Show Styles", "Mostrar estilos"), + ("Show the Typography panel", "Mostrar o painel Tipografia"), + ("Show/Hide Document Grid", "Mostrar/ocultar grade do documento"), + ("Shrink Ruby Width Automatically", "Reduzir automaticamente a largura do ruby"), + ("Similar to {family}", "Semelhantes a {family}"), + ("Simplified Chinese Kinsoku", "Kinsoku de chinês simplificado"), + ("Simulate Paper Color", "Simular cor do papel"), + ("Single Left Quotation Mark", "Aspa simples de abertura"), + ("Single Right Quotation Mark", "Aspa simples de fechamento"), + ("Sixth Space", "Espaço de um sexto"), + ("Slab Serif", "Serifa quadrada"), + ("Slug:", "Área de informações:"), + ("Small Black Circle", "Círculo preto pequeno"), + ("Small Sample Text Size", "Tamanho de amostra pequeno"), + ("Small White Circle", "Círculo branco pequeno"), + ("Smart Guide Options", "Opções de guias inteligentes"), + ("Smooth Tool", "Ferramenta Suavizar"), + ("Soft Kinsoku", "Kinsoku flexível"), + ("Sort Entries in Alphabetical Order", "Classificar entradas em ordem alfabética"), + ("Start #", "Nº inicial"), + ("Start Chapter Numbering at:", "Iniciar numeração de capítulos em:"), + ("Start Page Numbering at:", "Iniciar numeração de páginas em:"), + ("Start Section", "Iniciar seção"), + ("Step and Repeat", "Passo e repetição"), + ("Stop Preview", "Parar visualização"), + ("Story Column Direction", "Direção das colunas do artigo"), + ("Story Editor — {count} words", "Editor de artigo — {count} palavra(s)"), + ("Story {story} ← story {parent}", "Artigo {story} ← artigo {parent}"), + ("Straight Double Quotation Marks", "Aspas duplas retas"), + ("Straight Single Quotation Mark (Apostrophe)", "Aspa simples reta (apóstrofo)"), + ("Stroke Behind Fill", "Traçado atrás do preenchimento"), + ("Stroke Weight", "Peso do traçado"), + ("Styles in Table of Contents", "Estilos no sumário"), + ("Stylistic Set", "Conjunto estilístico"), + ("Subject:", "Assunto:"), + ("Subscript:", "Subscrito:"), + ("Superscript:", "Sobrescrito:"), + ("Swap Text Fill and Stroke", "Alternar preenchimento e traçado do texto"), + ("Symbol", "Símbolo"), + ("Table of Contents", "Sumário"), + ("Table/Convert Cell Type", "Tabela/Converter tipo de célula"), + ("Table/Delete", "Tabela/Excluir"), + ("Table/Insert", "Tabela/Inserir"), + ("Table/Select", "Tabela/Selecionar"), + ("Tate-chu-yoko Digits:", "Dígitos de tate-chu-yoko:"), + ("Tate-chu-yoko Settings", "Configurações de tate-chu-yoko"), + ("Text Anchor", "Âncora de texto"), + ("Text Color", "Cor do texto"), + ("Text:", "Texto:"), + ("The selected fonts have no OpenType features.", "As fontes selecionadas não têm recursos OpenType."), + ("Thin Space", "Espaço fino"), + ("Third Space", "Espaço de um terço"), + ( + "This document uses unavailable fonts. Choose replacements, or select Done to continue with fallback fonts.", + "Este documento usa fontes indisponíveis. Escolha outras fontes ou selecione Concluir para continuar com as fontes substitutas.", + ), + ("This page", "Esta página"), + ("This page is", "Esta página é"), + ( + "This story was edited in the layout: you'll be asked before those edits are replaced", + "Este artigo foi editado no layout: você será consultado antes que essas edições sejam substituídas", + ), + ("This style is in use. Text using it will be reassigned to:", "Este estilo está em uso. O texto que o usa será reatribuído a:"), + ("Thread Text Frames", "Encadear quadros de texto"), + ("Title Case", "Iniciais Maiúsculas"), + ("Title:", "Título:"), + ("To Body", "Para o corpo"), + ("To Footer", "Para o rodapé"), + ("To Header", "Para o cabeçalho"), + ("Trademark Symbol", "Símbolo de marca comercial"), + ("Traditional Chinese Kinsoku", "Kinsoku de chinês tradicional"), + ("Transform Again Individually", "Transformar novamente individualmente"), + ("Transform Sequence Again", "Transformar sequência novamente"), + ("Transform Sequence Again Individually", "Transformar sequência novamente individualmente"), + ("TrueType font", "Fonte TrueType"), + ("Type Tool", "Ferramenta Texto"), + ( + "Type a word with ~ at each allowed break (ex~am~ple); without ~ the word is never hyphenated.", + "Digite uma palavra com ~ em cada quebra permitida (ex~em~plo); sem ~ a palavra nunca é hifenizada.", + ), + ("Type on a Path Options", "Opções de texto no demarcador"), + ("Type on a Path Tool", "Ferramenta Texto no demarcador"), + ("Type/Bulleted and Numbered Lists", "Tipo/Listas com marcadores e numeradas"), + ("Type/Change Case", "Tipo/Alterar maiúsculas/minúsculas"), + ("Type/Hyperlinks & Cross-References", "Tipo/Hiperlinks e referências cruzadas"), + ("Type/Insert Break Character", "Tipo/Inserir caractere de quebra"), + ("Type/Insert Special Character/Hyphens and Dashes", "Tipo/Inserir caractere especial/Hifens e travessões"), + ("Type/Insert Special Character/Markers", "Tipo/Inserir caractere especial/Marcadores"), + ("Type/Insert Special Character/Other", "Tipo/Inserir caractere especial/Outros"), + ("Type/Insert Special Character/Quotation Marks", "Tipo/Inserir caractere especial/Aspas"), + ("Type/Insert Special Character/Symbols", "Tipo/Inserir caractere especial/Símbolos"), + ("Type/Insert White Space", "Tipo/Inserir espaço"), + ("Type/Proof Setup", "Tipo/Configuração de prova"), + ("Type/Rotate Spread", "Tipo/Girar spread"), + ("Type/Separations Preview", "Tipo/Visualização de separações"), + ("Type/Story Direction", "Tipo/Direção do texto"), + ("Type/Text Variables", "Tipo/Variáveis de texto"), + ("Type/Track Changes", "Tipo/Controlar alterações"), + ("Type/Type on a Path", "Tipo/Texto no demarcador"), + ("URL:", "URL:"), + ("Unassigned", "Não atribuído"), + ("Unlimited", "Ilimitado"), + ("Unsaved Changes", "Alterações não salvas"), + ("Up/Down Position:", "Posição acima/abaixo:"), + ("Update Data Source", "Atualizar fonte de dados"), + ("Use Document Style Definition", "Usar a definição de estilo do documento"), + ("Use OpenType Pro Ruby Glyphs", "Usar glifos ruby do OpenType Pro"), + ("Use Paragraph Gyoudori", "Usar gyoudori do parágrafo"), + ("Username", "Nome de usuário"), + ("Validate from Root Element", "Validar a partir do elemento raiz"), + ("Variable font", "Fonte variável"), + ("Vertical Type Tool", "Ferramenta Texto vertical"), + ("Vertical Type on a Path Tool", "Ferramenta Texto vertical no demarcador"), + ("View/Display Performance", "Exibir/Desempenho de exibição"), + ("View/Extras", "Exibir/Extras"), + ("View/Grids & Guides", "Exibir/Grades e guias"), + ("View/Proof Setup", "Exibir/Configuração de prova"), + ("View/Rotate Spread", "Exibir/Girar spread"), + ("View/Screen Mode", "Exibir/Modo de tela"), + ("View/Separations Preview", "Exibir/Visualização de separações"), + ("View/Structure", "Exibir/Estrutura"), + ("Warichu", "Warichu"), + ("Warichu Settings", "Configurações de warichu"), + ("Warichu Size:", "Tamanho do warichu:"), + ("White Circle", "Círculo branco"), + ("White Sesame Dot", "Ponto de gergelim branco"), + ("White Triangle", "Triângulo branco"), + ("Window/Arrange", "Janela/Organizar"), + ("Window/Interactive", "Janela/Interativo"), + ("Window/Interface Color Theme", "Janela/Tema de cores da interface"), + ("Window/Output", "Janela/Saída"), + ("Window/Panels", "Janela/Painéis"), + ("Window/Type & Tables", "Janela/Texto e tabelas"), + ("Window/Utilities", "Janela/Utilitários"), + ("Working CMYK", "CMYK de trabalho"), + ("Your Recent Items", "Seus itens recentes"), + ("Zoom Tool", "Ferramenta Zoom"), + ("[No Language]", "[Nenhum idioma]"), + ("a, b, c, d...", "a, b, c, d..."), + ("dropcap", "capitular"), + ("i, ii, iii, iv...", "i, ii, iii, iv..."), + ("image editing", "edição de imagens"), + ("layer", "camada"), + ("layers", "camadas"), + ("lowercase", "minúsculas"), + ("motion graphics and visual effects", "motion graphics e efeitos visuais"), + ("photo library and raw development", "biblioteca de fotos e revelação raw"), + ("reading, organizing and protecting PDFs", "leitura, organização e proteção de PDFs"), + ("the default printer", "a impressora padrão"), + ("vector illustration", "ilustração vetorial"), + ("video editing, color and sound", "edição de vídeo, cor e som"), + ("{contributors} contributors · {commits} commits", "{contributors} colaboradores · {commits} commits"), + ("{count} conflict(s): {names}", "{count} conflito(s): {names}"), + ("{count} match(es)", "{count} correspondência(s)"), + ("{count} replacement(s) made", "{count} substituição(ões) feita(s)"), + ("{label}: {name} (double-click: Color Picker)", "{label}: {name} (clique duplo: Seletor de cores)"), + ( + "{name}: import cancelled while a dialog is open; choose the file again", + "{name}: importação cancelada enquanto uma caixa de diálogo estava aberta; escolha o arquivo novamente", + ), + ("{name}: {count} step(s) done", "{name}: {count} etapa(s) concluída(s)"), + ("« Add", "« Adicionar"), + ( + "“{}” has been edited in this layout. Updating replaces those edits with the text from the file.", + "“{}” foi editado neste layout. A atualização substitui essas edições pelo texto do arquivo.", + ), + ("“{}” has changes that are not saved. Save them before closing?", "“{}” tem alterações não salvas. Salvá-las antes de fechar?"), + ("Close Window", "Fechar janela"), + ("Deselect Guides", "Desmarcar guias"), + ("Import InDesign Shortcut Set", "Importar conjunto de atalhos do InDesign"), + ( + "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.", + "{count} atalho(s) importado(s) de “{set}”; {unmatched} item(ns) sem correspondência, {unreadable} ilegível(is).", + ), + ("Maximize/Restore Window", "Maximizar/restaurar janela"), + ("Minimize Window", "Minimizar janela"), + ("Move Guides", "Mover guias"), + ("Move Window", "Mover janela"), + ("PRs", "PRs"), + ("Resize Window", "Redimensionar janela"), + ("Select Guide", "Selecionar guia"), + // Strings main added from 2026-10-23 to 2026-11-04 (Keyboard Shortcuts sets, panel docking, + // OpenType features, page presets, the Package summary, command parameter names). + ("(cycles)", "(alterna)"), + ("(modified)", "(modificado)"), + ("+Bin", "+Bin"), + ("+LOC", "+LOC"), + ("180°", "180°"), + ("A command can have at most {n} shortcuts", "Um comando pode ter no máximo {n} atalhos"), + ("A set with this name exists", "Já existe um conjunto com este nome"), + ("Action", "Ação"), + ("Add a shortcut to {label}", "Adicionar um atalho a {label}"), + ("Adjust layout", "Ajustar layout"), + ("Advance seconds", "Segundos para avançar"), + ("Affix in", "Afixos em"), + ("All Commands", "Todos os comandos"), + ("All Inks", "Todas as tintas"), + ("Allow", "Permitir"), + ("Also used by {label}", "Também usado por {label}"), + ("Alt", "Alt"), + ("Anchor", "Âncora"), + ("Application", "Aplicativo"), + ("Approximated", "Aproximado"), + ("Artifact", "Artefato"), + ("Asset", "Recurso"), + ("Assets", "Recursos"), + ("Assign", "Atribuir"), + ("Assign to Selected", "Atribuir ao selecionado"), + ("Assign {chord} to {label}", "Atribuir {chord} a {label}"), + ("At end", "No final"), + ("Attrs", "Atributos"), + ("Author", "Autor"), + ("Auto fit", "Ajuste automático"), + ("Auto size", "Dimensionamento automático"), + ("Auto size reference", "Referência do dimensionamento automático"), + ("Auto-Collapse Iconic Panels", "Recolher automaticamente painéis em ícones"), + ("Axis", "Eixo"), + ("Bands", "Faixas"), + ("Based on:", "Baseado em:"), + ("Bookmarks panel", "Painel Indicadores"), + ("Built-in sets cannot be changed", "Conjuntos integrados não podem ser alterados"), + ("Cancel Import", "Cancelar importação"), + ("Cancel the new shortcut", "Cancelar o novo atalho"), + ("Caps Lock", "Caps Lock"), + ("Case", "Caixa"), + ("Changed", "Alterado"), + ("Check for Updates…", "Verificar atualizações…"), + ("Choose a command…", "Escolher um comando…"), + ("Click a key to see its shortcuts", "Clique em uma tecla para ver seus atalhos"), + ("Close Other Panels in Group", "Fechar outros painéis do grupo"), + ("Close panel window", "Fechar janela do painel"), + ("Colors and Inks: {process} process, {spot} spot", "Cores e tintas: {process} de escala, {spot} especiais"), + ("Column gutter", "Medianiz da coluna"), + ("Column width", "Largura da coluna"), + ("Columns kind", "Tipo de colunas"), + ("Command", "Comando"), + ("Command for {item}", "Comando para {item}"), + ("Commands", "Comandos"), + ("Conflicts", "Conflitos"), + ("Context:", "Contexto:"), + ("Continue across stories", "Continuar entre artigos"), + ("Copies", "Cópias"), + ("Copy Report", "Copiar relatório"), + ("Corners", "Cantos"), + ("Cover image", "Imagem de capa"), + ("Ctrl", "Ctrl"), + ("Current shortcuts", "Atalhos atuais"), + ("Current shortcuts:", "Atalhos atuais:"), + ("Currently assigned to:", "Atribuído atualmente a:"), + ("Default: {chord}", "Padrão: {chord}"), + ("Definition", "Definição"), + ("Delete Points", "Excluir pontos"), + ("Delete Surrounding Text", "Excluir texto ao redor"), + ("Delete the set “{set}”?", "Excluir o conjunto “{set}”?"), + ("Detail pane size", "Tamanho do painel de detalhes"), + ("Dir", "Pasta/direção"), + ("Dry run", "Simulação"), + ("EPUB Split Document", "Dividir documento EPUB"), + ("Embed fonts", "Embutir fontes"), + ("Embedded images: {embedded}", "Imagens embutidas: {embedded}"), + ("End", "Fim"), + ("Expand", "Expandir"), + ("Expand Panels", "Expandir painéis"), + ("Export…", "Exportar…"), + ("Extension", "Extensão"), + ("Facing pages", "Páginas frente a frente"), + ("Family", "Família"), + ("File Info", "Informações do arquivo"), + ("File Info…", "Informações do arquivo…"), + ("Filter:", "Filtro:"), + ("First baseline", "Primeira linha de base"), + ("First baseline min", "Mín. da primeira linha de base"), + ("Fit to", "Ajustar a"), + ("Fixed Keys", "Teclas fixas"), + ("Fixed layout", "Layout fixo"), + ("Flatten", "Achatar"), + ("Float Tab Group", "Flutuar grupo de guias"), + ("Font size", "Tamanho da fonte"), + ("Fonts: {used} in use, {missing} unavailable", "Fontes: {used} em uso, {missing} indisponíveis"), + ("Format", "Formato"), + ("From", "De"), + ("Full screen", "Tela cheia"), + ("Go to Conflict", "Ir para o conflito"), + ("Grep", "GREP"), + ("Group Tab Group with", "Agrupar grupo de guias com"), + ("Heading", "Intertítulo"), + ("Heading style", "Estilo de intertítulo"), + ("Hidden", "Oculto"), + ("Historical Forms", "Formas históricas"), + ("Horizontal Kana", "Kana horizontal"), + ("Identifier", "Identificador"), + ("Idml", "IDML"), + ("Ids", "IDs"), + ("Import", "Importar"), + ("Import as New Set", "Importar como novo conjunto"), + ("Import “{set}”", "Importar “{set}”"), + ("Imported", "Importado"), + ("Imported as “{set}”", "Importado como “{set}”"), + ("Import…", "Importar…"), + ("Include inside edges", "Incluir bordas internas"), + ("Include prefix", "Incluir prefixo"), + ("Instructions", "Instruções"), + ("Intent", "Finalidade"), + ("Justification Alternates", "Alternativas de justificação"), + ("Keep Both", "Manter ambos"), + ("Keep within column", "Manter dentro da coluna"), + ("Key: {key}", "Tecla: {key}"), + ("Keyboard", "Teclado"), + ("Keyboard Shortcuts (JSON)", "Atalhos de teclado (JSON)"), + ("Keyboard: US", "Teclado: EUA"), + ("Keyboard: US (macOS)", "Teclado: EUA (macOS)"), + ("Kind", "Tipo"), + ("Label", "Rótulo"), + ("Latex", "LaTeX"), + ("Layout page", "Página de layout"), + ("Legal", "Legal"), + ("Letter", "Carta"), + ("Letter - Half", "Carta - metade"), + ("Line breaks", "Quebras de linha"), + ( + "Links and Images: {found} linked, {modified} changed, {missing} not found", + "Links e imagens: {found} vinculados, {modified} alterados, {missing} não encontrados", + ), + ("Load cursor", "Cursor carregado"), + ("Localized Forms", "Formas localizadas"), + ("Lock Workspace", "Bloquear área de trabalho"), + ("Mark Positioning", "Posicionamento de marcas"), + ("Marker", "Marcador"), + ("Marks", "Marcas"), + ("Matched without change", "Correspondido sem alteração"), + ("Maximize Frame", "Maximizar painel"), + ("Message", "Mensagem"), + ("Minimum height", "Altura mínima"), + ("Minimum width", "Largura mínima"), + ("Missing only", "Somente ausentes"), + ("Mobile 1080 × 1920", "Celular 1080 × 1920"), + ("Mobile 390 × 844", "Celular 390 × 844"), + ("Mode", "Modo"), + ("Modifier on", "Modificador em"), + ("More", "Mais"), + ("Move Panel to New Window", "Mover painel para nova janela"), + ("Move panel column", "Mover coluna de painéis"), + ("Move panel window", "Mover janela do painel"), + ("Move tab group", "Mover grupo de guias"), + ("New Set…", "Novo conjunto…"), + ("New shortcut:", "Novo atalho:"), + ("New shortcut: {chord}", "Novo atalho: {chord}"), + ("Not assigned", "Não atribuído"), + ("Not changed", "Não alterado"), + ("Not matched", "Sem correspondência"), + ("Nothing uses this key", "Nada usa esta tecla"), + ("Now used by {label}", "Agora usado por {label}"), + ("Number", "Número"), + ("Object point", "Ponto do objeto"), + ("Object/Convert Point", "Objeto/Converter ponto"), + ("Optical Margin Size", "Tamanho da margem óptica"), + ("Option", "Option"), + ("Org", "Origem"), + ("PDF/X-1a:2003", "PDF/X-1a:2003"), + ("Page break before", "Quebra de página antes"), + ("Page layout", "Layout da página"), + ("Panel or context", "Painel ou contexto"), + ("Para style", "Estilo de parágrafo"), + ("Paragraphs", "Parágrafos"), + ("Parent", "Página principal"), + ("Parent Page Size", "Tamanho da página principal"), + ("Pattern", "Padrão de traço"), + ("Pdf", "PDF"), + ("Pdf crop", "Corte do PDF"), + ("Pdf page", "Página do PDF"), + ("Phone", "Telefone"), + ("Pos", "Pos."), + ("Postcard", "Cartão-postal"), + ("Prefix", "Prefixo"), + ("Primary text frame", "Quadro de texto principal"), + ("Printer", "Impressora"), + ("Proportional Metrics", "Métricas proporcionais"), + ("Rasterize", "Rasterizar"), + ("Rect", "Retângulo"), + ("Redefine styles", "Redefinir estilos"), + ("Ref", "Ref."), + ("Ref char style", "Estilo de caractere da ref."), + ("Ref position", "Posição da ref."), + ("Remove existing", "Remover existentes"), + ("Remove {chord} from {label}", "Remover {chord} de {label}"), + ("Rename…", "Renomear…"), + ("Reset All", "Redefinir tudo"), + ("Reset All…", "Redefinir tudo…"), + ("Restart", "Reiniciar"), + ("Restore Frame Size", "Restaurar tamanho do painel"), + ("Restore every default shortcut in this set?", "Restaurar todos os atalhos padrão deste conjunto?"), + ("Restore the default for {label}", "Restaurar o padrão de {label}"), + ("Revert Set", "Reverter conjunto"), + ("Roman Italics", "Itálico romano"), + ("Row gutter", "Medianiz entre linhas"), + ("Rule", "Linha"), + ("Run in", "Em sequência"), + ("Save changes to “{set}”?", "Salvar as alterações em “{set}”?"), + ("Scope", "Escopo"), + ("Search by key", "Pesquisar por tecla"), + ("Section headings", "Títulos de seção"), + ("Select Parent Page", "Selecionar página principal"), + ("Select a command first", "Selecione um comando primeiro"), + ("Select a command to see and change its shortcuts", "Selecione um comando para ver e alterar seus atalhos"), + ("Separator", "Separador"), + ("Set name:", "Nome do conjunto:"), + ("Set:", "Conjunto:"), + ("Shape", "Forma"), + ("Shift", "Shift"), + ("Shortcut", "Atalho"), + ("Shortcut Sets", "Conjuntos de atalhos"), + ("Show:", "Mostrar:"), + ("Skipped", "Pulados"), + ("Space", "Espaço"), + ("Space after", "Espaço depois"), + ("Space before", "Espaço antes"), + ("Space between", "Espaço entre"), + ("Span columns", "Abranger colunas"), + ("Start", "Início"), + ("Start at", "Começar em"), + ("Start number", "Número inicial"), + ("Start page", "Página inicial"), + ("Story Options", "Opções de artigo"), + ("Stylistic Alternates", "Alternativas estilísticas"), + ("Subject", "Assunto"), + ("Subpath", "Subdemarcador"), + ("Suffix", "Sufixo"), + ("Suggestions", "Sugestões"), + ("Suggestions ({n})", "Sugestões ({n})"), + ("Summarize…", "Resumir…"), + ("Super", "Super"), + ("Tablet 1024 × 1366", "Tablet 1024 × 1366"), + ("Tabloid", "Tabloide"), + ("Tagged", "Com tags"), + ("Take It", "Usar este"), + ("Take It and Go to Conflict", "Usar este e ir para o conflito"), + ("Taken from other commands", "Retirados de outros comandos"), + ("Target", "Destino"), + ("The file is too large", "O arquivo é grande demais"), + ("The name is empty, too long or has control characters", "O nome está vazio, é longo demais ou contém caracteres de controle"), + ("The scope {scope} is unknown", "O escopo {scope} é desconhecido"), + ("The set was not found", "O conjunto não foi encontrado"), + ("There are too many sets", "Há conjuntos demais"), + ("This command is unknown", "Este comando é desconhecido"), + ("This file is from a newer version (format {n})", "Este arquivo é de uma versão mais recente (formato {n})"), + ("This is not a craft-keymap file", "Este não é um arquivo craft-keymap"), + ("This is not a shortcut set file", "Este não é um arquivo de conjunto de atalhos"), + ("Threshold", "Limiar"), + ("Title", "Título"), + ("To family", "Para a família"), + ("To spread", "Para a página dupla"), + ("To style", "Para o estilo"), + ("Toc title", "Título do sumário"), + ("Toggle Text and Cell Selection", "Alternar seleção de texto e de célula"), + ("Tolerance", "Tolerância"), + ("Too many shortcuts are assigned", "Há atalhos demais atribuídos"), + ("Tools", "Ferramentas"), + ("Topics", "Tópicos"), + ("Transparency Flattener", "Achatamento de transparência"), + ("Type…", "Tipo…"), + ("Unreadable", "Ilegível"), + ("Url", "URL"), + ("Use", "Usar"), + ("Use Default", "Usar padrão"), + ("Use minimum height", "Usar altura mínima"), + ("Use minimum width", "Usar largura mínima"), + ("Value", "Valor"), + ("Visible", "Visível"), + ("Web 1024 × 768", "Web 1024 × 768"), + ("Web 1366 × 768", "Web 1366 × 768"), + ("Web 1920 × 1080", "Web 1920 × 1080"), + ("Will change", "Será alterado"), + ("Win", "Win"), + ("With Shortcut", "Com atalho"), + ("Word", "Palavra"), + ("X offset", "Deslocamento X"), + ("X relative", "X relativo a"), + ("Y offset", "Deslocamento Y"), + ("empty shortcut", "atalho vazio"), + ("in a context that is not imported", "em um contexto que não é importado"), + ("key sequences are not supported", "sequências de teclas não são suportadas"), + ("line {n}", "linha {n}"), + ("modifier {key} given twice", "modificador {key} informado duas vezes"), + ("more than one command fits", "mais de um comando corresponde"), + ("no command has this name", "nenhum comando tem este nome"), + ("numeric keypad key read as main key", "tecla do teclado numérico lida como tecla principal"), + ("only a command of the other kind fits (tool or menu command)", "só corresponde um comando do outro tipo (ferramenta ou comando de menu)"), + ("press keys", "pressione as teclas"), + ("reserved by the system", "reservado pelo sistema"), + ("taken by a later entry", "ocupado por uma entrada posterior"), + ("taken by {label}", "ocupado por {label}"), + ("the scope is unknown", "o escopo é desconhecido"), + ("the scope name is not valid", "o nome do escopo não é válido"), + ("the set's shortcuts could not be added", "os atalhos do conjunto não puderam ser adicionados"), + ("the shortcut has no key", "o atalho não tem tecla"), + ("the shortcut is too long", "o atalho é longo demais"), + ("the shortcuts could not be removed", "os atalhos não puderam ser removidos"), + ("unknown key {key}", "tecla desconhecida {key}"), + ("unknown modifier {key}", "modificador desconhecido {key}"), + ("unsupported modifier {key}", "modificador não suportado {key}"), + ("{chord} (Default)", "{chord} (padrão)"), + ("{chord} (press again for a double press)", "{chord} (pressione de novo para um toque duplo)"), + ("{chord} ({scope})", "{chord} ({scope})"), + ("{chord} is reserved by the system", "{chord} é reservado pelo sistema"), + ("{chord} was removed from {label}", "{chord} foi removido de {label}"), + ("{chord}: {label}", "{chord}: {label}"), + ("{key}: listed more than once", "{key}: listado mais de uma vez"), + ("{key}: no command has this name", "{key}: nenhum comando tem este nome"), + ("{key}: part of the entry was dropped", "{key}: parte da entrada foi descartada"), + ("{key}: the scope in “{text}” is unknown", "{key}: o escopo em “{text}” é desconhecido"), + ("{key}: {text} is reserved by the system", "{key}: {text} é reservado pelo sistema"), + ("{key}: “{text}” is not a shortcut ({error})", "{key}: “{text}” não é um atalho ({error})"), + ("{label} (Changed)", "{label} (alterado)"), + ("{label} (cycles)", "{label} (alterna)"), + ("{label} ({scope})", "{label} ({scope})"), + ("{label} ({scope}, both work)", "{label} ({scope}, ambos funcionam)"), + ("{label} keeps its shortcuts (matched by a panel menu name)", "{label} mantém seus atalhos (correspondido pelo nome de um menu de painel)"), + ("{label}: press keys", "{label}: pressione as teclas"), + ("{label}: {chord}", "{label}: {chord}"), + ("{label}: {chord} (Now used by {holder})", "{label}: {chord} (agora usado por {holder})"), + ("{menu} Menu", "Menu {menu}"), + ("{n} command changed", "{n} comando alterado"), + ("{n} command will change", "{n} comando será alterado"), + ("{n} commands changed", "{n} comandos alterados"), + ("{n} commands will change", "{n} comandos serão alterados"), + ("{n} entries cut at a limit", "{n} entradas cortadas em um limite"), + ("{n} entries were ignored: too many commands", "{n} entradas foram ignoradas: comandos demais"), + ("{n} entry cut at a limit", "{n} entrada cortada em um limite"), + ("{n} entry was ignored: too many commands", "{n} entrada foi ignorada: comandos demais"), + ("{n} item could not be read", "{n} item não pôde ser lido"), + ("{n} item not matched", "{n} item sem correspondência"), + ("{n} item without a shortcut", "{n} item sem atalho"), + ("{n} items could not be read", "{n} itens não puderam ser lidos"), + ("{n} items not matched", "{n} itens sem correspondência"), + ("{n} items without a shortcut", "{n} itens sem atalho"), + ("{n} key read approximately", "{n} tecla lida aproximadamente"), + ("{n} keys read approximately", "{n} teclas lidas aproximadamente"), + ("{n} saved shortcut could not be used", "{n} atalho salvo não pôde ser usado"), + ("{n} saved shortcuts could not be used", "{n} atalhos salvos não puderam ser usados"), + ("{n} shortcut also works in another scope", "{n} atalho também funciona em outro escopo"), + ("{n} shortcut is assigned more than once", "{n} atalho está atribuído mais de uma vez"), + ("{n} shortcuts also work in another scope", "{n} atalhos também funcionam em outro escopo"), + ("{n} shortcuts are assigned more than once", "{n} atalhos estão atribuídos mais de uma vez"), + ("{panel} Panel", "Painel {panel}"), + ("{title} ({n})", "{title} ({n})"), + ("{title}: {set}", "{title}: {set}"), + ("ΔLOC", "ΔLOC"), + ("−Bin", "−Bin"), + ("−LOC", "−LOC"), + ("⚠ {chord} is used by {label}", "⚠ {chord} é usado por {label}"), ]; diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index e0173ba11..178cc9bc3 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1490,6 +1490,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set", "Задать"), ("unused", "не используется"), ("⚠ Missing", "⚠ Отсутствует"), + ("Checking", "Проверка"), + ("Link status is unavailable.", "Состояние связей недоступно."), ("▲ Modified", "▲ Изменено"), ("(Text Color)", "(Цвет текста)"), ("{count} glyphs — click to insert at the text cursor", "Глифов: {count} — щелкните, чтобы вставить в позиции текстового курсора"), diff --git a/crates/ui-egui/src/i18n/sv.rs b/crates/ui-egui/src/i18n/sv.rs index d2371732b..766ba32da 100644 --- a/crates/ui-egui/src/i18n/sv.rs +++ b/crates/ui-egui/src/i18n/sv.rs @@ -852,6 +852,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set", "Ange"), ("unused", "oanvänd"), ("⚠ Missing", "⚠ Saknas"), + ("Checking", "Kontrollerar"), + ("Link status is unavailable.", "Länkstatus är inte tillgänglig."), ("▲ Modified", "▲ Ändrad"), ("(Text Color)", "(Textfärg)"), ("{count} glyphs — click to insert at the text cursor", "{count} glyfer – klicka för att infoga vid textmarkören"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index 13e0a1bd0..ebde2b629 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -4148,6 +4148,17 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Set", ["Set", "Jeu", "Set", "Set", "样式集"]), ("unused", ["unused", "inutilisé", "unused", "unused", "未使用"]), ("⚠ Missing", ["⚠ Missing", "⚠ Manquant", "⚠ Missing", "⚠ Missing", "⚠ 缺失"]), + ("Checking", ["Wird geprüft", "Vérification", "Comprobando", "確認中", "正在检查"]), + ( + "Link status is unavailable.", + [ + "Der Verknüpfungsstatus ist nicht verfügbar.", + "L'état des liens n'est pas disponible.", + "El estado de los vínculos no está disponible.", + "リンクの状態を取得できません。", + "链接状态不可用。", + ], + ), ("▲ Modified", ["▲ Modified", "▲ Modifié", "▲ Modified", "▲ Modified", "▲ 已修改"]), ("(Text Color)", ["(Text Color)", "(Couleur du texte)", "(Text Color)", "(Text Color)", "(文本颜色)"]), ( diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index b676a0c5f..f873e5b8a 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -249,6 +249,23 @@ fn arrangement_accessible_name_is_localized_in_every_interface_language() { } } +/// The Links panel's captions for a status its background check hasn't found yet, and for when +/// the check can't run. +#[test] +fn links_panel_check_captions_are_localized_in_every_interface_language() { + for (code, name) in LANGUAGES { + for key in ["Checking", "Link status is unavailable."] { + let label = tr(code, key); + if code.is_empty() { + assert_eq!(label, key, "{name}"); + } else { + assert_ne!(label, key, "{name} still shows the English caption"); + assert!(!label.trim().is_empty(), "{name}: {key}"); + } + } + } +} + /// Checks language-specific context precedence and fallback to the plain translation. #[test] fn context_captions_come_from_their_context_or_the_plain_translation() { @@ -466,8 +483,18 @@ fn interface_strings() -> HashSet<&'static str> { keys.extend(crate::credits::SortKey::ALL.iter().flat_map(|key| [key.label().0, key.label().1])); // Docking chrome labels reach `tr` through craft-ui's localizer. keys.extend(crate::panel_docking::DOCK_LABELS.iter().map(|(_, english)| *english)); + // File › New's Units list passes each unit's name to `tr`. + keys.extend(crate::new_document::NEW_DOCUMENT_UNITS.map(|unit| unit.label())); // UI labels passed to `tr` that no other catalog has yet. keys.extend([ + "Auto-Sizing", + "Color Theme", + "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.", + "Minimum Height", + "Minimum Width", + "No Line Breaks", + "Reference Point", + "This document uses unavailable fonts. Choose replacements, or select Done to continue with fallback fonts.", "180°", "Create Links When Placing Text Files", "Delete Character Style…", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 6bdb6135f..4b2853eae 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1676,6 +1676,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Set", "Установити"), ("unused", "не використовується"), ("⚠ Missing", "⚠ Немає"), + ("Checking", "Перевірка"), + ("Link status is unavailable.", "Стан зв’язків недоступний."), ("▲ Modified", "▲ Змінено"), ("(Text Color)", "(Колір тексту)"), ("{count} glyphs — click to insert at the text cursor", "Гліфів: {count} — клацніть, щоб вставити в позиції текстового курсора"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6775c7755..40bdfbcb3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,8 @@ pub mod export_batch_dialog; pub mod i18n; pub mod icons; pub mod keys; +#[cfg(not(target_arch = "wasm32"))] +mod links_worker; pub mod menus; mod modal_drag; mod new_document; @@ -152,6 +154,10 @@ pub struct DesignApp { pub(crate) page_history: HashMap, /// The canvas cache and rect of the pane in `pane` (the other pane's are in `other_pane`). pub canvas: CanvasCache, + /// Native link checks run separately because an unavailable network share may block for an + /// operating-system timeout. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) links_worker: links_worker::State, pub canvas_rect: Option, /// Window › Arrange › Split Window: two views of the document side by side. pub split: bool, diff --git a/crates/ui-egui/src/links_worker.rs b/crates/ui-egui/src/links_worker.rs new file mode 100644 index 000000000..6e207d969 --- /dev/null +++ b/crates/ui-egui/src/links_worker.rs @@ -0,0 +1,942 @@ +//! Native background link checks for the Links panel. +//! +//! Whether a linked file is still there and unchanged is asked of the file system, and a network +//! share that doesn't answer keeps the caller waiting for an operating-system timeout. The +//! panel's rows therefore come from the document alone, on the UI thread, and their statuses +//! from a check on a worker thread: the statuses last found stay until newer ones arrive, so an +//! unavailable share can't stop the application from repainting or accepting input. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError, Weak}; +use std::time::{Duration, Instant}; +use std::{hash::Hash, hash::Hasher}; + +use designcraft_compose::Cache; +use designcraft_doc::{AssetId, Document, Fingerprint}; +use designcraft_engine::cmd::LinkListChecks; +use serde_json::Value; + +// A document edit is checked at once. External changes (a file restored or edited elsewhere) are +// polled: an unchanged link costs one metadata call, but on a slow share with hundreds of links a +// check can itself take seconds, so the next one waits at least as long as the last took and the +// worker is idle at least half the time. +const REFRESH_MIN: Duration = Duration::from_secs(2); +const REFRESH_MAX: Duration = Duration::from_secs(5 * 60); +// A check for another document than the one shown is told to stop. One still running this long +// after is blocked in a file system call: the document shown gets a fresh worker instead of +// waiting behind it, and the blocked one ends when its call returns. +const SUPERSEDE_AFTER: Duration = Duration::from_millis(200); +// Workers alive at once, counting replaced ones still blocked in a file system call. Switching +// documents while a share is down can't pile up threads beyond this. +const MAX_WORKERS: usize = 4; +// A worker that couldn't be started, or that ended on its own (a panic under the link check), +// is replaced after this long: one failure doesn't leave the panel without statuses for the +// rest of the session, and a failure that repeats isn't retried on every frame. +const RETRY_AFTER: Duration = Duration::from_secs(30); +// Documents whose statuses are kept: going back to one shows them at once. +const MAX_DOCUMENTS: usize = 8; + +/// A document as a check sees it: which one, which state of it, and where it is saved (relative +/// links resolve against that). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Key { + pub(crate) uid: u64, + document: usize, + path: u64, +} + +impl Key { + pub(crate) fn new(uid: u64, document: &Arc, path: Option<&str>) -> Self { + let mut path_hash = std::collections::hash_map::DefaultHasher::new(); + path.hash(&mut path_hash); + Self { uid, document: Arc::as_ptr(document) as usize, path: path_hash.finish() } + } +} + +#[derive(Clone)] +pub(crate) struct Snapshot { + pub(crate) key: Key, + pub(crate) doc: Arc, + pub(crate) cache: Arc, + pub(crate) document: Option, +} + +pub(crate) struct Rows { + /// The document's links as it is now, each with the status last found for it (`checking` + /// until one is). + pub(crate) values: Arc>, + /// No check can run for now (the worker couldn't be started, or ended): statuses may be old. + pub(crate) unavailable: bool, + /// Linked images a check just read and found unchanged, for + /// [`designcraft_engine::cmd::remember_link_files`]: only when the check was of the document + /// as it is now. + pub(crate) verified: Vec<(AssetId, Fingerprint)>, +} + +struct Job { + token: u64, + snapshot: Snapshot, + /// Set by the UI thread to end the check before its next file. + stop: Arc, +} + +struct Done { + token: u64, + key: Key, + /// Keeps the checked document's allocation, so no later document can reuse the address in + /// `key` and pass for it once this one is dropped. + pin: Weak, + /// The checked rows; `None` when the check was stopped. + rows: Option>, + verified: Vec<(AssetId, Fingerprint)>, + /// How long the check took and when it ended, on the worker's clock: the result may wait to + /// be read (the panel can be hidden meanwhile). + took: Duration, + finished: Instant, +} + +enum Poll { + Done(Done), + Empty, + Disconnected, +} + +type Runner = Box Option> + Send>; + +/// What the checks remember (files already compared), handed from job to job and from a replaced +/// worker to the next. Locked only between jobs, never while a file is asked for. +type Pool = Arc>; + +/// Counts a worker thread as alive until it ends, however it ends. Its end frees a slot below +/// [`MAX_WORKERS`], so the UI is woken to replace a worker it was waiting behind. +struct Alive { + live: Arc, + ctx: egui::Context, +} + +impl Drop for Alive { + fn drop(&mut self) { + self.live.fetch_sub(1, Ordering::Relaxed); + self.ctx.request_repaint(); + } +} + +struct Worker { + tx: std::sync::mpsc::Sender, + rx: std::sync::mpsc::Receiver, +} + +impl Worker { + fn spawn(ctx: egui::Context, live: Arc, pool: Pool) -> Option { + Self::spawn_with( + ctx, + live, + pool, + Box::new(|snapshot, checks, stop| { + designcraft_engine::cmd::list_document_links(&snapshot.doc, &snapshot.cache, snapshot.document.as_deref(), checks, stop) + }), + ) + } + + fn spawn_with(ctx: egui::Context, live: Arc, pool: Pool, mut run: Runner) -> Option { + let (tx, jobs) = std::sync::mpsc::channel::(); + let (done_tx, rx) = std::sync::mpsc::channel::(); + live.fetch_add(1, Ordering::Relaxed); + let alive = Alive { live, ctx: ctx.clone() }; + std::thread::Builder::new() + .name("designcraft-links".into()) + .spawn(move || { + let _alive = alive; + while let Ok(Job { token, snapshot, stop }) = jobs.recv() { + let (key, pin, started) = (snapshot.key, Arc::downgrade(&snapshot.doc), Instant::now()); + let mut checks = std::mem::take(&mut *pool.lock().unwrap_or_else(PoisonError::into_inner)); + let rows = run(&snapshot, &mut checks, &stop); + let verified = checks.take_verified(); + pool.lock().unwrap_or_else(PoisonError::into_inner).absorb(checks); + // Before the result is sent: whoever receives it finds the document no longer + // held here (an edited document isn't kept alive by its check). + drop(snapshot); + let done = Done { token, key, pin, rows, verified, took: started.elapsed(), finished: Instant::now() }; + if done_tx.send(done).is_err() { + break; + } + ctx.request_repaint(); + } + }) + .ok()?; + Some(Self { tx, rx }) + } + + fn submit(&self, job: Job) -> bool { + self.tx.send(job).is_ok() + } + + fn poll(&self) -> Poll { + let mut latest = None; + loop { + match self.rx.try_recv() { + Ok(done) => latest = Some(done), + Err(std::sync::mpsc::TryRecvError::Empty) => return latest.map_or(Poll::Empty, Poll::Done), + Err(std::sync::mpsc::TryRecvError::Disconnected) => return latest.map_or(Poll::Disconnected, Poll::Done), + } + } + } +} + +/// How long a check that took `took` stays current. +fn refresh_after(took: Duration) -> Duration { + took.clamp(REFRESH_MIN, REFRESH_MAX) +} + +/// The document's rows with, where a status waits for the files, the one the last check found +/// for the same link (the same graphic or story, at the same path). +fn with_statuses(document: &[Value], checked: Option<&[Value]>) -> Vec { + let Some(checked) = checked else { return document.to_vec() }; + let link = |row: &Value| (row["asset"].as_u64(), row["story"].as_u64(), row["path"].as_str().map(str::to_owned)); + let found: HashMap<_, &Value> = checked.iter().map(|row| (link(row), &row["status"])).collect(); + document + .iter() + .map(|row| { + let mut row = row.clone(); + if row["status"] == "checking" + && let Some(status) = found.get(&link(&row)) + && let Some(fields) = row.as_object_mut() + { + fields.insert("status".into(), (*status).clone()); + } + row + }) + .collect() +} + +/// The latest check of a document. +struct Checked { + key: Key, + /// As in [`Done`]: `key` names this document only while its allocation is kept. + _pin: Weak, + rows: Vec, + finished: Instant, + /// When its files are asked again. + due: Instant, +} + +/// The rows of the document shown, from the document alone. +struct Structure { + key: Key, + _pin: Weak, + rows: Vec, +} + +struct InFlight { + token: u64, + key: Key, + stop: Arc, + /// When the check was told to stop. + stopping: Option, +} + +#[cfg(test)] +type Spawn = Box, Pool) -> Option + Send + Sync>; + +/// What tests put in place of the worker's check and of the document's rows. +#[cfg(test)] +struct Hooks { + spawn: Spawn, + preview: Box Vec + Send + Sync>, +} + +#[derive(Default)] +pub(crate) struct State { + worker: Option, + /// When to try again after the worker couldn't be started or was found ended. + retry_at: Option, + next_token: u64, + in_flight: Option, + /// The latest check of each document shown lately, by uid. + checked: HashMap, + structure: Option, + /// `structure` with the statuses of the check that ended at the given time (none: `None`): + /// what the panel draws. + shown: Option<(Option, Arc>)>, + live: Arc, + pool: Pool, + #[cfg(test)] + hooks: Option, +} + +impl State { + pub(crate) fn rows(&mut self, ctx: &egui::Context, snapshot: Snapshot) -> Rows { + let key = snapshot.key; + let mut verified = Vec::new(); + match self.worker.as_ref().map(Worker::poll) { + Some(Poll::Done(done)) => { + if self.in_flight.as_ref().is_some_and(|job| job.token == done.token) { + self.in_flight = None; + } + // A check of an earlier state of the document still says how its files are: its + // statuses are shown while the next check runs. What it read vouches only for + // the bytes it compared, so that is passed on for this very state alone. + if let Some(rows) = done.rows { + if done.key == key { + verified = done.verified; + } + self.keep(Checked { + key: done.key, + _pin: done.pin, + rows, + finished: done.finished, + due: done.finished + refresh_after(done.took), + }); + } + } + Some(Poll::Disconnected) => { + self.worker = None; + self.retry_at = Some(Instant::now() + RETRY_AFTER); + self.in_flight = None; + } + Some(Poll::Empty) | None => {} + } + + // A check of another document than the one shown ends before its next file. If a file + // system call keeps it (a share that doesn't answer), it is left to finish on its own, + // its result dropped with its channel, and the document shown gets a fresh worker. + if let Some(job) = self.in_flight.as_mut().filter(|job| job.key.uid != key.uid) { + if job.stopping.is_none() { + job.stop.store(true, Ordering::Relaxed); + job.stopping = Some(Instant::now()); + } + let waited = job.stopping.map_or(Duration::ZERO, |at| at.elapsed()); + if waited < SUPERSEDE_AFTER { + // A blocked worker never asks for a repaint, so come back when it can be replaced. + ctx.request_repaint_after(SUPERSEDE_AFTER.saturating_sub(waited)); + } else if self.live.load(Ordering::Relaxed) < MAX_WORKERS { + self.worker = None; + self.in_flight = None; + } + } + if self.retry_at.is_some_and(|at| Instant::now() >= at) { + self.retry_at = None; + } + if self.worker.is_none() && self.retry_at.is_none() { + self.worker = self.spawn(ctx); + if self.worker.is_none() { + self.retry_at = Some(Instant::now() + RETRY_AFTER); + } + } + + let due = self.checked.get(&key.uid).is_none_or(|checked| checked.key != key || Instant::now() >= checked.due); + if due && self.in_flight.is_none() && self.retry_at.is_none() { + self.next_token = self.next_token.wrapping_add(1); + let (token, stop) = (self.next_token, Arc::new(AtomicBool::new(false))); + if self.worker.as_ref().is_some_and(|worker| worker.submit(Job { token, snapshot: snapshot.clone(), stop: Arc::clone(&stop) })) { + self.in_flight = Some(InFlight { token, key, stop, stopping: None }); + } else { + self.worker = None; + self.retry_at = Some(Instant::now() + RETRY_AFTER); + } + } + + let values = self.shown(&snapshot); + if let Some(at) = self.retry_at { + // Nothing else wakes the panel for the next try. + ctx.request_repaint_after(at.saturating_duration_since(Instant::now())); + } else if self.in_flight.is_none() + && let Some(checked) = self.checked.get(&key.uid).filter(|checked| checked.key == key) + { + ctx.request_repaint_after(checked.due.saturating_duration_since(Instant::now())); + } + Rows { values, unavailable: self.retry_at.is_some(), verified } + } + + fn spawn(&self, ctx: &egui::Context) -> Option { + let (live, pool) = (Arc::clone(&self.live), Arc::clone(&self.pool)); + #[cfg(test)] + if let Some(hooks) = &self.hooks { + return (hooks.spawn)(ctx.clone(), live, pool); + } + Worker::spawn(ctx.clone(), live, pool) + } + + /// The document's rows, looking at no file: an edit shows at once, whatever the worker is + /// waiting for. + fn preview(&self, snapshot: &Snapshot) -> Vec { + #[cfg(test)] + if let Some(hooks) = &self.hooks { + return (hooks.preview)(snapshot); + } + designcraft_engine::cmd::preview_document_links(&snapshot.doc, &snapshot.cache, snapshot.document.as_deref()) + } + + /// The rows to draw: built again only when the document or its latest check changes. + fn shown(&mut self, snapshot: &Snapshot) -> Arc> { + let key = snapshot.key; + if self.structure.as_ref().is_none_or(|structure| structure.key != key) { + let rows = self.preview(snapshot); + self.structure = Some(Structure { key, _pin: Arc::downgrade(&snapshot.doc), rows }); + self.shown = None; + } + let checked = self.checked.get(&key.uid); + let checked_at = checked.map(|checked| checked.finished); + if let Some((at, rows)) = &self.shown + && *at == checked_at + { + return Arc::clone(rows); + } + let document = self.structure.as_ref().map_or(&[][..], |structure| &structure.rows); + let rows = Arc::new(with_statuses(document, checked.map(|checked| checked.rows.as_slice()))); + self.shown = Some((checked_at, Arc::clone(&rows))); + rows + } + + fn keep(&mut self, checked: Checked) { + self.checked.insert(checked.key.uid, checked); + if self.checked.len() > MAX_DOCUMENTS + && let Some(oldest) = self.checked.iter().min_by_key(|(_, checked)| checked.finished).map(|(uid, _)| *uid) + { + self.checked.remove(&oldest); + } + } + + #[cfg(test)] + pub(crate) fn checking(&self) -> bool { + self.in_flight.is_some() + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn snapshot(key: Key) -> Snapshot { + Snapshot { key, doc: Arc::new(Document::new(&Default::default())), cache: Arc::new(Cache::new()), document: None } + } + + fn key(uid: u64, document: usize) -> Key { + Key { uid, document, path: 0 } + } + + fn row(asset: u64, status: &str) -> Value { + json!({"asset": asset, "name": format!("{asset}.png"), "path": format!("/links/{asset}.png"), "status": status}) + } + + fn statuses(rows: &Rows) -> Vec { + rows.values.iter().map(|row| row["status"].as_str().unwrap_or_default().to_string()).collect() + } + + /// Something a test's check waits for: opened by the test, or after two seconds so a failing + /// test can't hang. + #[derive(Clone, Default)] + struct Gate(Arc<(Mutex, std::sync::Condvar)>); + + impl Gate { + fn new() -> Self { + let gate = Self::default(); + let timeout = gate.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(2)); + timeout.open(); + }); + gate + } + + fn open(&self) { + *self.0.0.lock().unwrap() = true; + self.0.1.notify_all(); + } + + fn wait(&self) { + let mut open = self.0.0.lock().unwrap(); + while !*open { + open = self.0.1.wait(open).unwrap(); + } + } + } + + /// A state whose document rows are `preview` and whose workers check with `run`; and how many + /// workers it has started. + fn state( + preview: impl Fn(&Snapshot) -> Vec + Send + Sync + 'static, + run: impl Fn(&Snapshot, &AtomicBool) -> Option> + Send + Sync + 'static, + ) -> (State, Arc) { + let (run, started) = (Arc::new(run), Arc::new(AtomicUsize::new(0))); + let count = Arc::clone(&started); + let spawn: Spawn = Box::new(move |ctx, live, pool| { + count.fetch_add(1, Ordering::Relaxed); + let run = Arc::clone(&run); + Worker::spawn_with(ctx, live, pool, Box::new(move |snapshot, _, stop| run(snapshot, stop))) + }); + (State { hooks: Some(Hooks { spawn, preview: Box::new(preview) }), ..Default::default() }, started) + } + + /// Ask for the rows as the panel does, frame after frame, until no check is in flight. + fn settle(state: &mut State, ctx: &egui::Context, key: Key) -> Rows { + let mut rows = state.rows(ctx, snapshot(key)); + for _ in 0..1000 { + if !state.checking() { + break; + } + std::thread::sleep(Duration::from_millis(2)); + rows = state.rows(ctx, snapshot(key)); + } + rows + } + + /// A worker the test answers for: the jobs sent to it, and where its results go. + fn by_hand(state: &mut State) -> (std::sync::mpsc::Receiver, std::sync::mpsc::Sender) { + let (tx, jobs) = std::sync::mpsc::channel::(); + let (done_tx, rx) = std::sync::mpsc::channel::(); + state.worker = Some(Worker { tx, rx }); + (jobs, done_tx) + } + + fn done(token: u64, key: Key, rows: Vec) -> Done { + Done { token, key, pin: Weak::new(), rows: Some(rows), verified: Vec::new(), took: Duration::ZERO, finished: Instant::now() } + } + + #[test] + fn the_rows_are_there_while_the_check_is_blocked() { + let ctx = egui::Context::default(); + let gate = Gate::new(); + let blocked = gate.clone(); + let (mut state, _) = state( + |_| vec![row(1, "checking")], + move |_, _| { + blocked.wait(); + Some(vec![row(1, "ok")]) + }, + ); + + let before = Instant::now(); + let rows = state.rows(&ctx, snapshot(key(1, 1))); + let elapsed = before.elapsed(); + + assert!(elapsed < Duration::from_millis(500), "the UI call waited {elapsed:?}"); + assert_eq!(statuses(&rows), ["checking"], "the document's rows, before any file answered"); + assert!(state.checking()); + gate.open(); + assert_eq!(statuses(&settle(&mut state, &ctx, key(1, 1))), ["ok"]); + } + + #[test] + fn an_edit_shows_at_once_with_the_statuses_last_found() { + let ctx = egui::Context::default(); + let gate = Gate::new(); + let blocked = gate.clone(); + // The edit (document 2) places a second graphic; its check doesn't answer for a while. + let (mut state, _) = state( + |snapshot| if snapshot.key.document == 1 { vec![row(1, "checking")] } else { vec![row(1, "checking"), row(2, "checking")] }, + move |snapshot, _| { + if snapshot.key.document == 1 { + return Some(vec![row(1, "modified")]); + } + blocked.wait(); + Some(vec![row(1, "ok"), row(2, "ok")]) + }, + ); + assert_eq!(statuses(&settle(&mut state, &ctx, key(1, 1))), ["modified"]); + + let rows = state.rows(&ctx, snapshot(key(1, 2))); + + assert_eq!(statuses(&rows), ["modified", "checking"], "the new graphic is listed, the other keeps its status"); + assert!(state.checking()); + gate.open(); + assert_eq!(statuses(&settle(&mut state, &ctx, key(1, 2))), ["ok", "ok"]); + } + + #[test] + fn a_check_of_an_earlier_edit_still_fills_in_statuses() { + let ctx = egui::Context::default(); + let (first, second) = (Gate::new(), Gate::new()); + let (wait_first, wait_second) = (first.clone(), second.clone()); + let (mut state, _) = state( + |_| vec![row(1, "checking")], + move |snapshot, _| { + if snapshot.key.document == 1 { + wait_first.wait(); + return Some(vec![row(1, "missing")]); + } + wait_second.wait(); + Some(vec![row(1, "ok")]) + }, + ); + // The document is edited while its check runs: the check isn't thrown away, or typing + // faster than a slow share answers would leave the statuses as they were for good. + state.rows(&ctx, snapshot(key(1, 1))); + assert_eq!(statuses(&state.rows(&ctx, snapshot(key(1, 2)))), ["checking"]); + first.open(); + let mut rows = state.rows(&ctx, snapshot(key(1, 2))); + for _ in 0..1000 { + if statuses(&rows) != ["checking"] { + break; + } + std::thread::sleep(Duration::from_millis(2)); + rows = state.rows(&ctx, snapshot(key(1, 2))); + } + + assert_eq!(statuses(&rows), ["missing"], "what the earlier check found is shown"); + assert!(state.checking(), "and the document as it is now is checked next"); + second.open(); + assert_eq!(statuses(&settle(&mut state, &ctx, key(1, 2))), ["ok"]); + } + + #[test] + fn a_check_is_timed_by_the_worker_not_by_when_its_result_is_read() { + let ctx = egui::Context::default(); + let (mut state, _) = state(|_| vec![row(1, "checking")], |_, _| None); + let (_jobs, results) = by_hand(&mut state); + state.rows(&ctx, snapshot(key(1, 1))); + // The panel may be hidden while a check ends: what counts is when it ended, there. + let (finished, took) = (Instant::now(), Duration::from_secs(4)); + results.send(Done { took, finished, ..done(1, key(1, 1), vec![row(1, "ok")]) }).unwrap(); + + let rows = state.rows(&ctx, snapshot(key(1, 1))); + + assert_eq!(statuses(&rows), ["ok"]); + let checked = &state.checked[&1]; + assert_eq!((checked.finished, checked.due), (finished, finished + took)); + } + + #[test] + fn external_changes_are_polled_within_seconds_and_slow_checks_space_out() { + assert_eq!(refresh_after(Duration::from_millis(30)), REFRESH_MIN, "a quick check is repeated after two seconds"); + assert_eq!(refresh_after(Duration::from_secs(4)), Duration::from_secs(4), "a slow share is idle as long as it was busy"); + assert_eq!(refresh_after(Duration::from_secs(3600)), REFRESH_MAX); + } + + #[test] + fn the_files_are_asked_again_when_a_check_is_due() { + let ctx = egui::Context::default(); + let (mut state, _) = state(|_| vec![row(1, "checking")], |_, _| Some(vec![row(1, "ok")])); + assert_eq!(statuses(&settle(&mut state, &ctx, key(1, 1))), ["ok"]); + state.rows(&ctx, snapshot(key(1, 1))); + assert!(!state.checking(), "a check just made is current"); + + state.checked.get_mut(&1).unwrap().due = Instant::now(); + state.rows(&ctx, snapshot(key(1, 1))); + + assert!(state.checking(), "an external change shows within the interval"); + } + + #[test] + fn a_check_of_another_document_is_stopped_and_its_worker_goes_on() { + let ctx = egui::Context::default(); + // Document 1's check has many files to go; like the engine's, it looks at the stop flag + // between them. + let (mut state, started) = state( + |snapshot| vec![row(snapshot.key.uid, "checking")], + |snapshot, stop| { + if snapshot.key.uid == 1 { + while !stop.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(1)); + } + return None; + } + Some(vec![row(2, "ok")]) + }, + ); + state.rows(&ctx, snapshot(key(1, 1))); + + let rows = settle(&mut state, &ctx, key(2, 1)); + + assert_eq!(statuses(&rows), ["ok"]); + assert_eq!(started.load(Ordering::Relaxed), 1, "no thread is left behind for a check that can be stopped"); + } + + #[test] + fn a_blocked_check_of_another_document_does_not_gate_the_one_shown() { + let ctx = egui::Context::default(); + let gate = Gate::new(); + let blocked = gate.clone(); + // Document 1's links are on a share that doesn't answer. + let (mut state, started) = state( + |snapshot| vec![row(snapshot.key.uid, "checking")], + move |snapshot, _| { + if snapshot.key.uid == 1 { + blocked.wait(); + } + Some(vec![row(snapshot.key.uid, "ok")]) + }, + ); + state.rows(&ctx, snapshot(key(1, 1))); + state.rows(&ctx, snapshot(key(2, 1))); + std::thread::sleep(SUPERSEDE_AFTER); + + let rows = settle(&mut state, &ctx, key(2, 1)); + gate.open(); + + assert_eq!(statuses(&rows), ["ok"], "document 2 waited behind document 1's blocked check"); + assert_eq!(started.load(Ordering::Relaxed), 2); + } + + #[test] + fn a_switch_before_the_takeover_is_due_schedules_a_repaint_for_it() { + let ctx = egui::Context::default(); + let delays = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&delays); + ctx.set_request_repaint_callback(move |info| seen.lock().unwrap().push(info.delay)); + let gate = Gate::new(); + let blocked = gate.clone(); + let (mut state, started) = state( + |_| Vec::new(), + move |_, _| { + blocked.wait(); + None + }, + ); + state.rows(&ctx, snapshot(key(1, 1))); + delays.lock().unwrap().clear(); + + state.rows(&ctx, snapshot(key(2, 1))); + + assert!(state.in_flight.as_ref().is_some_and(|job| job.key.uid == 1 && job.stop.load(Ordering::Relaxed)), "told to stop, not replaced yet"); + assert_eq!(started.load(Ordering::Relaxed), 1); + let delays = delays.lock().unwrap(); + assert!(delays.iter().any(|delay| *delay <= SUPERSEDE_AFTER), "no repaint scheduled for the takeover: {delays:?}"); + gate.open(); + } + + #[test] + fn a_replaced_worker_that_ends_wakes_the_document_waiting_at_the_limit() { + let ctx = egui::Context::default(); + let repaints = Arc::new(AtomicUsize::new(0)); + let seen = Arc::clone(&repaints); + ctx.set_request_repaint_callback(move |_| { + seen.fetch_add(1, Ordering::Relaxed); + }); + // Documents 1 and 2 are on shares that don't answer until released. + let (first, second) = (Gate::new(), Gate::new()); + let (wait_first, wait_second) = (first.clone(), second.clone()); + let (mut state, started) = state( + |snapshot| vec![row(snapshot.key.uid, "checking")], + move |snapshot, _| { + match snapshot.key.uid { + 1 => wait_first.wait(), + 2 => wait_second.wait(), + _ => {} + } + Some(vec![row(snapshot.key.uid, "ok")]) + }, + ); + // Two slots left: document 1's worker, then document 2's, which replaces it. + state.live = Arc::new(AtomicUsize::new(MAX_WORKERS - 2)); + let show = |state: &mut State, uid: u64| { + state.rows(&ctx, snapshot(key(uid, 1))); + std::thread::sleep(SUPERSEDE_AFTER); + state.rows(&ctx, snapshot(key(uid, 1))) + }; + show(&mut state, 1); + show(&mut state, 2); + show(&mut state, 3); + assert!(state.in_flight.as_ref().is_some_and(|job| job.key.uid == 2), "document 3 waits at the limit"); + assert_eq!((state.live.load(Ordering::Relaxed), started.load(Ordering::Relaxed)), (MAX_WORKERS, 2)); + + repaints.store(0, Ordering::Relaxed); + first.open(); + let woken = (0..1000).any(|_| { + std::thread::sleep(Duration::from_millis(2)); + repaints.load(Ordering::Relaxed) > 0 && state.live.load(Ordering::Relaxed) < MAX_WORKERS + }); + let rows = settle(&mut state, &ctx, key(3, 1)); + second.open(); + + assert!(woken, "the ended worker freed its slot and asked for a repaint"); + assert_eq!(statuses(&rows), ["ok"], "document 3 got a worker once a slot was free"); + assert_eq!(started.load(Ordering::Relaxed), 3); + } + + #[test] + fn replaced_workers_are_bounded() { + let ctx = egui::Context::default(); + let (mut state, started) = state(|_| Vec::new(), |_, _| None); + let (_jobs, _results) = by_hand(&mut state); + state.live = Arc::new(AtomicUsize::new(MAX_WORKERS)); + state.in_flight = Some(InFlight { token: 7, key: key(1, 1), stop: Arc::default(), stopping: Some(Instant::now()) }); + std::thread::sleep(SUPERSEDE_AFTER); + + state.rows(&ctx, snapshot(key(2, 1))); + + assert_eq!(state.in_flight.as_ref().map(|job| job.token), Some(7), "at the limit the document shown waits instead"); + assert_eq!(started.load(Ordering::Relaxed), 0); + } + + #[test] + fn a_worker_that_fails_is_replaced_after_a_while() { + let ctx = egui::Context::default(); + let delays = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&delays); + ctx.set_request_repaint_callback(move |info| seen.lock().unwrap().push(info.delay)); + let (mut state, started) = state(|_| vec![row(1, "checking")], |_, _| Some(vec![row(1, "ok")])); + // The first worker can't be started; the next ones can. + let hooks = state.hooks.take().unwrap(); + let (attempts, spawn) = (Arc::new(AtomicUsize::new(0)), hooks.spawn); + let count = Arc::clone(&attempts); + let spawn: Spawn = Box::new(move |ctx, live, pool| if count.fetch_add(1, Ordering::Relaxed) == 0 { None } else { spawn(ctx, live, pool) }); + state.hooks = Some(Hooks { spawn, preview: hooks.preview }); + + let rows = settle(&mut state, &ctx, key(1, 1)); + assert!(rows.unavailable); + assert_eq!(statuses(&rows), ["checking"], "the document's rows are still listed"); + assert!(state.rows(&ctx, snapshot(key(1, 1))).unavailable, "not tried again on the next frame"); + assert_eq!(attempts.load(Ordering::Relaxed), 1); + let retry = |delay: &Duration| *delay > RETRY_AFTER / 2 && *delay <= RETRY_AFTER; + assert!(delays.lock().unwrap().iter().any(retry), "no repaint scheduled for the next try"); + + state.retry_at = Some(Instant::now()); + let rows = settle(&mut state, &ctx, key(1, 1)); + assert!(!rows.unavailable); + assert_eq!(statuses(&rows), ["ok"]); + + // The same for a worker found ended: its channels are closed. + let (jobs, results) = by_hand(&mut state); + drop((jobs, results)); + let rows = state.rows(&ctx, snapshot(key(1, 1))); + assert!(rows.unavailable); + assert_eq!(statuses(&rows), ["ok"], "the statuses last found stay"); + state.retry_at = Some(Instant::now()); + state.checked.clear(); + let rows = settle(&mut state, &ctx, key(1, 1)); + assert!(!rows.unavailable); + assert_eq!(statuses(&rows), ["ok"]); + assert_eq!((attempts.load(Ordering::Relaxed), started.load(Ordering::Relaxed)), (3, 2)); + } + + #[test] + fn statuses_are_kept_for_each_document_lately_shown() { + let ctx = egui::Context::default(); + let (mut state, _) = state(|snapshot| vec![row(snapshot.key.uid, "checking")], |snapshot, _| Some(vec![row(snapshot.key.uid, "ok")])); + settle(&mut state, &ctx, key(1, 1)); + settle(&mut state, &ctx, key(2, 1)); + + let rows = state.rows(&ctx, snapshot(key(1, 1))); + + assert_eq!(statuses(&rows), ["ok"], "going back to a document shows its statuses at once"); + assert!(!state.checking(), "and a check still current isn't made again"); + for uid in 3..=(MAX_DOCUMENTS as u64 + 2) { + settle(&mut state, &ctx, key(uid, 1)); + } + assert_eq!(state.checked.len(), MAX_DOCUMENTS); + assert!(!state.checked.contains_key(&1), "the document checked longest ago goes first"); + } + + #[test] + fn files_found_unchanged_are_passed_on_only_for_the_document_as_it_was_checked() { + let ctx = egui::Context::default(); + let (mut state, _) = state(|_| vec![row(1, "checking")], |_, _| None); + let (_jobs, results) = by_hand(&mut state); + let found = vec![(AssetId(1), Fingerprint { size: 4, mtime: 9 })]; + state.rows(&ctx, snapshot(key(1, 1))); + results.send(Done { verified: found.clone(), ..done(1, key(1, 1), vec![row(1, "ok")]) }).unwrap(); + assert_eq!(state.rows(&ctx, snapshot(key(1, 1))).verified, found); + assert_eq!(state.rows(&ctx, snapshot(key(1, 1))).verified, [], "once"); + + // Edited since the check started: other bytes may be linked now. + state.rows(&ctx, snapshot(key(1, 2))); + results.send(Done { verified: found, ..done(2, key(1, 2), vec![row(1, "modified")]) }).unwrap(); + let rows = state.rows(&ctx, snapshot(key(1, 3))); + assert_eq!(rows.verified, []); + assert_eq!(statuses(&rows), ["modified"], "its statuses are still shown"); + } + + #[test] + fn what_a_check_remembers_goes_to_the_worker_that_replaces_it() { + let dir = std::env::temp_dir().join(format!("dc-links-worker-pool-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("photo.png"); + std::fs::write(&path, designcraft_render::Rendered { width: 2, height: 2, pixels: vec![200; 16] }.to_png()).unwrap(); + let age = |secs: u64| { + let file = std::fs::File::options().write(true).open(&path).unwrap(); + file.set_modified(std::time::SystemTime::now() - Duration::from_secs(secs)).unwrap(); + }; + // Placed, then given a new time (a restored folder): the check has to compare its bytes. + age(300); + let mut session = designcraft_engine::Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap(); + age(60); + let doc = Arc::clone(&session.doc().unwrap().doc); + let real = Snapshot { key: Key::new(1, &doc, None), doc, cache: Arc::clone(&session.cache), document: None }; + // Each worker says how much it was handed before checking. + let (handed_tx, handed) = std::sync::mpsc::channel(); + let handed_tx = Mutex::new(handed_tx); + let spawn: Spawn = Box::new(move |ctx, live, pool| { + let handed = handed_tx.lock().unwrap().clone(); + Worker::spawn_with( + ctx, + live, + pool, + Box::new(move |snapshot, checks, stop| { + let _ = handed.send(checks.len()); + designcraft_engine::cmd::list_document_links(&snapshot.doc, &snapshot.cache, None, checks, stop) + }), + ) + }); + let ctx = egui::Context::default(); + let mut state = State { hooks: Some(Hooks { spawn, preview: Box::new(|_| Vec::new()) }), ..Default::default() }; + let check = |state: &mut State| { + state.rows(&ctx, real.clone()); + for _ in 0..1000 { + if !state.checking() { + break; + } + std::thread::sleep(Duration::from_millis(2)); + state.rows(&ctx, real.clone()); + } + }; + + check(&mut state); + // A replaced worker: as when a check of another document was blocked. + state.worker = None; + state.checked.clear(); + check(&mut state); + + assert_eq!(handed.try_iter().collect::>(), [0, 1], "the second worker starts from what the first compared"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_check_keeps_its_documents_address_from_being_reused() { + let ctx = egui::Context::default(); + let (mut state, _) = state(|_| Vec::new(), |_, _| Some(Vec::new())); + let doc = Arc::new(Document::new(&Default::default())); + let key = Key::new(1, &doc, None); + let snapshot = || Snapshot { key, doc: Arc::clone(&doc), cache: Arc::new(Cache::new()), document: None }; + state.rows(&ctx, snapshot()); + for _ in 0..1000 { + if !state.checking() { + break; + } + std::thread::sleep(Duration::from_millis(2)); + state.rows(&ctx, snapshot()); + } + assert!(!state.checking(), "the check finished"); + let address = Arc::as_ptr(&doc); + + // Edits replace the document; the old one is dropped once nothing else holds it. + drop(doc); + + // A weak reference keeps the allocation, so no later document can be given the address + // in `key` while this check is kept. The worker let go of the document before it + // answered, so nothing else holds it. + let pin = state.checked.get(&1).map(|checked| &checked._pin); + assert!(pin.is_some_and(|pin| pin.as_ptr() == address && pin.strong_count() == 0), "the check doesn't hold its document's allocation"); + } + + #[test] + fn statuses_go_to_the_same_link_only() { + let document = + [row(1, "checking"), json!({"story": 1, "path": "/links/1.png", "status": "checking"}), row(2, "embedded"), row(3, "checking")]; + // Graphic 3 was relinked since: what was found is of another file. + let checked = [row(1, "modified"), json!({"asset": 3, "path": "/elsewhere/3.png", "status": "ok"}), row(2, "ok")]; + + let rows = with_statuses(&document, Some(&checked)); + + let status: Vec<_> = rows.iter().map(|row| row["status"].as_str().unwrap()).collect(); + assert_eq!(status, ["modified", "checking", "embedded", "checking"]); + assert_eq!(with_statuses(&document, None), document); + } +} diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index 0acdb36f4..195d9cb76 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -15,7 +15,7 @@ use crate::dialogs::Dialog; use crate::theme::{Tokens, semibold}; /// The Units list, as InDesign's New Document window has it. -const NEW_DOCUMENT_UNITS: [Unit; 8] = +pub(crate) const NEW_DOCUMENT_UNITS: [Unit; 8] = [Unit::Points, Unit::Picas, Unit::Inches, Unit::Millimeters, Unit::Centimeters, Unit::Ciceros, Unit::Agates, Unit::Pixels]; /// Tab key and English label. const TABS: [(&str, &str); 5] = [("recent", "Recent"), ("saved", "Saved"), ("print", "Print"), ("web", "Web"), ("mobile", "Mobile")]; diff --git a/crates/ui-egui/src/panels/properties/links.rs b/crates/ui-egui/src/panels/properties/links.rs index 8d13fe06d..5ee02ea79 100644 --- a/crates/ui-egui/src/panels/properties/links.rs +++ b/crates/ui-egui/src/panels/properties/links.rs @@ -35,19 +35,47 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } let Some(st) = app.session.active() else { return }; let t = Tokens::get(ui.ctx()); - // The list checks files on disk: refresh it at most twice a second. - let key = egui::Id::unique("links_panel"); - let now = ui.input(|i| i.time); - let rev = (st.uid, st.revision); - let cached: Option<(f64, (u64, u64), Vec)> = ui.data(|d| d.get_temp(key)); - let rows = match cached { - Some((at, r, rows)) if r == rev && now - at < 0.5 => rows, - _ => { - let rows = app.session.execute("links.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); - ui.data_mut(|d| d.insert_temp(key, (now, rev, rows.clone()))); - rows + // Whether a linked file is still there and unchanged is asked of the file system, and a + // network path may take an operating-system timeout to answer: native builds list the links + // from the document and take their statuses from a check on a background worker. + #[cfg(not(target_arch = "wasm32"))] + let refresh = { + let doc = std::sync::Arc::clone(&st.doc); + let snapshot = crate::links_worker::Snapshot { + key: crate::links_worker::Key::new(st.uid, &doc, st.path.as_deref()), + doc: std::sync::Arc::clone(&doc), + cache: std::sync::Arc::clone(&app.session.cache), + document: st.path.as_ref().map(std::path::PathBuf::from), + }; + let refresh = app.links_worker.rows(ui.ctx(), snapshot); + // A file the check read and found unchanged isn't read again, in this session or the next. + if !refresh.verified.is_empty() { + designcraft_engine::cmd::remember_link_files(&mut app.session, &doc, &refresh.verified); + } + refresh + }; + #[cfg(not(target_arch = "wasm32"))] + let rows = &refresh.values; + // Web builds have no filesystem to wait for: the list is refreshed at most twice a second. + #[cfg(target_arch = "wasm32")] + let rows = { + let key = egui::Id::unique("links_panel"); + let now = ui.input(|i| i.time); + let rev = (st.uid, st.revision); + let cached: Option<(f64, (u64, u64), Vec)> = ui.data(|d| d.get_temp(key)); + match cached { + Some((at, r, rows)) if r == rev && now - at < 0.5 => rows, + _ => { + let rows = app.session.execute("links.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + ui.data_mut(|d| d.insert_temp(key, (now, rev, rows.clone()))); + rows + } } }; + #[cfg(not(target_arch = "wasm32"))] + if refresh.unavailable && !rows.is_empty() { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Link status is unavailable.")).color(t.text_dim).size(11.0)); + } if rows.is_empty() { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No placed graphics.")).color(t.text_dim)); return; @@ -61,7 +89,7 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Status")).color(t.text_dim).size(11.0)); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page · PPI")).color(t.text_dim).size(11.0)); ui.end_row(); - for r in &rows { + for r in rows.iter() { // Asset and story ids come from one counter: a row is chosen by either. let aid = r["asset"].as_u64().or_else(|| r["story"].as_u64()).unwrap_or(0); let name = r["name"].as_str().unwrap_or(""); @@ -90,6 +118,7 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { "missing" => ("\u{26A0} Missing", egui::Color32::from_rgb(0xe5, 0x4b, 0x4b)), "modified" => ("\u{25B2} Modified", egui::Color32::from_rgb(0xe8, 0xb3, 0x2c)), "embedded" => ("Embedded", t.text_dim), + "checking" => ("Checking", t.text_dim), "tooLarge" => ("Too large to check", egui::Color32::from_rgb(0xe8, 0xb3, 0x2c)), _ => ("OK", t.text_dim), }; @@ -203,14 +232,22 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { /// first when a linked text story edited in the layout would lose those edits, as InDesign's /// Links panel does. The `links.update` command itself doesn't ask (as in InDesign scripting). pub fn update_links(app: &mut DesignApp, target: Value) { - let rows = app.session.execute("links.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); let one = target.get("story").and_then(Value::as_u64); let all = one.is_none() && target.get("asset").is_none(); - let edited: Vec<&str> = rows - .iter() - .filter(|r| (all || (one.is_some() && r["story"].as_u64() == one)) && r["edited"] == true && r["status"] == "modified") - .filter_map(|r| r["name"].as_str()) - .collect(); + let wanted = |r: &Value| (all || (one.is_some() && r["story"].as_u64() == one)) && r["edited"] == true; + // Which linked stories were edited in the layout is in the document. Only when one was are + // the files asked whether it changed too: `links.list` asks every linked file, on this + // thread, and a share that doesn't answer would hold up every other update. + let any_edited = app.session.active().is_some_and(|st| { + let document = st.path.as_deref().map(std::path::Path::new); + designcraft_engine::cmd::preview_document_links(&st.doc, &app.session.cache, document).iter().any(wanted) + }); + let rows = if any_edited { + app.session.execute("links.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default() + } else { + Vec::new() + }; + let edited: Vec<&str> = rows.iter().filter(|r| wanted(r) && r["status"] == "modified").filter_map(|r| r["name"].as_str()).collect(); if edited.is_empty() { let _ = run_update_links(app, target); } else { diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index cdbc9002d..1e7c40c8a 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -81,9 +81,24 @@ fn links_harness(app: DesignApp, width: f32) -> Harness<'static, DesignApp> { ); h.input_mut().max_texture_side = Some(8192); h.run_steps(3); + settle_links(&mut h); h } +/// The link statuses come from a background check: step until it has finished and its rows are +/// drawn. +fn settle_links(h: &mut Harness<'static, DesignApp>) { + for _ in 0..500 { + h.run_steps(1); + if !h.state().links_worker.checking() { + h.run_steps(1); + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("the background link check did not finish"); +} + /// With the caret in text, the Stroke panel offers the text's stroke options, not an empty panel. /// The first and last line indents take negative values, as InDesign's (and the Paragraph /// panel's) do; the left and right indents and the spacing don't. @@ -165,6 +180,7 @@ fn links_panel_distinguishes_same_named_sources_and_keeps_link_actions() { assert_eq!(h.state().session.active().unwrap().selection.items, vec![items[1]]); h.get_by_label("Embed").click(); h.run_steps(3); + settle_links(&mut h); assert!(h.state().session.active().unwrap().doc.assets[&assets[1]].link.is_none()); assert_eq!(h.state().session.active().unwrap().doc.assets[&assets[0]].link.as_deref(), Some(first)); assert!(h.query_by_label(second).is_none(), "an embedded graphic no longer shows a source path"); diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 5b3871bbf..0fcd2f3f6 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -856,9 +856,15 @@ fn dropping_an_ase_file_loads_its_swatches() { /// A document with one empty frame at (36, 36)-(136, 136): the app, the frame's id and a screen point inside it /// and one outside it. fn app_with_drop_frame() -> (DesignApp, u64, egui::Pos2, egui::Pos2) { + app_with_drop_item("frame.create", json!({"rect": [36, 36, 136, 136]})) +} + +/// A document with the one object `command` creates from `params`: the app, the object's id, the +/// screen point of page point (80, 80) and that of (300, 300), off the object. +fn app_with_drop_item(command: &str, params: serde_json::Value) -> (DesignApp, u64, egui::Pos2, egui::Pos2) { let mut app = DesignApp::new(Session::new(), Services::default()); app.run("file.new", json!({})).unwrap(); - let frame = app.run("frame.create", json!({"rect": [36, 36, 136, 136]})).unwrap()["id"].as_u64().unwrap(); + let frame = app.run(command, params).unwrap()["id"].as_u64().unwrap(); app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); let _ = app.view_mut(); let (view, rect) = (app.view().unwrap(), app.canvas_rect.unwrap()); @@ -959,6 +965,35 @@ fn a_file_dropped_without_pointer_events_fills_the_frame_under_the_system_pointe } } +/// A line encloses no area, so a graphic dropped on it gets a frame of its own at the drop point +/// and the line stays a line, as with the place cursor (#2290). A second graphic of the same drop +/// gets a frame of its own too, instead of replacing the first in the frame the first one got. +#[test] +fn graphics_dropped_on_a_line_leave_the_line_alone() { + let (mut app, line, on_line, _) = app_with_drop_item("line.create", json!({"a": [36, 80], "b": [136, 80]})); + let id = designcraft_doc::ItemId(line); + let before = serde_json::to_value(app.session.doc().unwrap().doc.item(id).unwrap()).unwrap(); + assert!(canvas::drop_frame(&app, Some(on_line)).is_none(), "a line is no frame"); + let (first, second) = (dropped_png("drop-on-line-first"), dropped_png("drop-on-line-second")); + let mut frame_free = true; + let a = app.place_dropped(&first.to_string_lossy(), Some(on_line), &mut frame_free); + assert!(!frame_free, "the first graphic is under the pointer now"); + let b = app.place_dropped(&second.to_string_lossy(), Some(on_line), &mut frame_free); + let _ = (std::fs::remove_file(&first), std::fs::remove_file(&second)); + let a = designcraft_doc::ItemId(a.unwrap()["id"].as_u64().unwrap()); + let b = designcraft_doc::ItemId(b.unwrap()["id"].as_u64().unwrap()); + assert!(a != id && b != id && a != b, "each graphic has a new frame: {a:?} {b:?}"); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.spreads[0].items.len(), 3); + assert_eq!(serde_json::to_value(d.item(id).unwrap()).unwrap(), before, "the line is unchanged"); + for (placed, name) in [(a, "drop-on-line-first"), (b, "drop-on-line-second")] { + let it = d.item(placed).unwrap(); + let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("{name} is in its frame") }; + assert!(d.assets[&g.asset].name.contains(name), "{name}"); + assert!(it.path.encloses_area() && it.bounds().height() > 1.0, "a real frame, not a zero-height one: {:?}", it.bounds()); + } +} + #[test] fn a_locked_frame_is_not_a_drop_target() { let (mut app, frame, inside, _) = app_with_drop_frame(); diff --git a/docs/gaps.md b/docs/gaps.md index d74de3b04..addb0b679 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; gap 16 shrunk: File › File Info; gap 16: Convert Variable to Text; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (gap 21 shrunk: pt-BR translates the whole interface; placed graphic layer-option preservation and explicit rendering warnings; gap 16 shrunk: File › File Info; gap 16: Convert Variable to Text; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -247,7 +247,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 21. Localization breadth (L) - **Missing:** Hindi, Indonesian, Korean, Vietnamese interfaces; ~17% of strings in the 83–85% - languages; 39% of pt-BR; native-speaker review for all. **Estimate:** 120–180 h. + languages; native-speaker review for all. **Estimate:** 120–180 h. **Doc:** [localization-parity.md](localization-parity.md) ### 22. InDesign scripting compatibility (E) @@ -278,6 +278,7 @@ Cue. These are excluded from every percentage. | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | Gap 21 shrunk: pt-BR translates 2,936 of 2,939 strings, all but three Control panel captions (61% before) | | 2026-10-22 | minor | Gap 26 shrunk: autoflow threads a frame per column on the pages that follow the story's last frame, then adds pages at the end of the document | | 2026-10-19 | minor | Gap 28 added for remaining placed-PDF font embedding, substitution or outlining; visible unembedded font detection now ships in PDF/X-4 export and `validate-pdf`, with invisible text exempt and operation- or depth-limited checks reported incomplete (#909) | | 2026-10-16 | minor | Gap 27 closed: the loaded text cursor threads into a text frame inside a group (#1075) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0e77995d4..b1b55d2fa 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** minor (Esperanto text language; Hungarian interface catalog, bundled-font coverage and flyout regression checks; Swedish interface) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** minor (pt-BR catalog filled; Russian listed; Esperanto text language; Hungarian interface catalog, bundled-font coverage and flyout regression checks; Swedish interface) · **Target:** Adobe InDesign 2026 (21.6) Per-language status of the DesignCraft interface and of document typesetting in each script. Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md), @@ -39,7 +39,7 @@ come as Middle East editions with an English or French interface and right-to-le | Hindi | hi | ✗ | 0 (0%) | none | Devanagari shaped by harfrust in documents but untested; no Hindi hyphenation or spelling | no | none | 20–30 | | Arabic | ar | Middle East edition (English/French UI) | 1,459 (85%) | menus, panels, most dialogs; no help | RTL UI text (`rtl.rs`: bidi + shaping); Arabic composition, kashida, digits, RTL stories and tables ✓ (arabic-typography.md); list bullets and multi-column RTL open (#100) | no | partial | 15–25 | | French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; French hyphenation ✓ (hyph-fr) | no | partial | 6–10 | -| Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; Portuguese hyphenation ✓ | no | partial | 8–12 | +| Portuguese (Brazil) | pt-br | ✓ (pt) | 2,936 of 2,939 (~100%, 2026-11-04 missing-translations report) | whole interface but the Control panel's `IT`, `VA` and `Y:` captions; no help | Latin ✓; Portuguese hyphenation ✓ | no | partial | 1–2 | | Indonesian | id | ✗ | 0 (0%) | none | Latin ✓ | no | none | 12–18 | | Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); canvas IME ✓, not checked by hand (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME checks, shared with zh) | | German | de | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; German 2006 hyphenation ✓ (hyph-de-1996, MIT) | no | partial | 6–10 | @@ -75,11 +75,20 @@ right-click flyouts in both Tools-panel layouts. Narrow-window coverage exercise scrolling, complete label/shortcut accessibility and selecting a scrolled row at 180 and 240 points; the existing wide-window checks keep their assertions. +Brazilian Portuguese (2026-11-04): `cargo xtask i18n-report` on main `ef6c3686` with this catalog +counts 2,939 interface strings; pt-BR translates 2,936 of them (up from 1,933). Like every other +language it leaves the Control panel's `IT`, `VA` and `Y:` captions in English. Tools are named "Ferramenta …", parent pages "página principal", stories "artigo" and +paths "demarcador" ("traçado" is Stroke; Pathfinder keeps its name). The 996 new strings were AI-drafted and approved by +the contributor; they are not yet checked in the running app, and a native-speaker review is +pending. Older rows still use "página-pai" and "página mestra" for parent pages and "Matéria" +for a story. + ## Other shipped languages | Language | Code | UI strings translated | Status | |---|---|---|---| | Hungarian | hu | 1,906 entries + 4 context captions (2026-11-01); initial catalog native-reviewed | partial | +| Russian | ru | 1,855 of 2,939 (63%, 2026-11-04 missing-translations report), count captions category-first | partial | | Czech | cs | 1,722 (99%), plural categories handled | partial | | Italian | it | 1,464 (85%) | partial | | Ukrainian | uk | 1,432 (83%), plural categories handled | partial | @@ -87,7 +96,7 @@ scrolling, complete label/shortcut accessibility and selecting a scrolled row at | Georgian | ka | 1,724 (~100%: every catalog key plus the command labels the others lack), count captions as `noun: n` | partial (reviewed by a native speaker) | | Swedish | sv | 2,857 of 2,891 (99%, 2026-11-01 missing-translations report): menus, panels, dialogs and messages; singular for a count of one; AI-drafted with Claude Code from the English source, spot-checked by a native speaker (not every string) | partial | -DesignCraft ships **6** interface languages beyond the twelve; InDesign ships 17 beyond its +DesignCraft ships **8** interface languages beyond the twelve; InDesign ships 17 beyond its eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, tr, uk, zh_TW). ## Document languages @@ -106,7 +115,7 @@ German variants have no automatic hyphenation; their German shaping and quotes a | Work | Opus 5.5 h | |---|---| | Fill the 83–85% catalogs (de, fr, es, zh, ar, it, uk; ~290 strings each) | 25–40 | -| pt-BR to full | 8–12 | +| pt-BR to full: check it in the running app; one term each for parent page and story | 1–2 | | New: Hindi, Indonesian, Korean, Vietnamese (1,724 strings, plural rules, tests) | 50–75 | | Canvas IME (zh, ja, ko): checks by hand with system IMEs, and fixes | 10–20 | | CJK UI font in every release build; Devanagari and Vietnamese rendering tests | 10–15 | @@ -118,6 +127,7 @@ German variants have no automatic hyphenation; their German shaping and quotes a | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | pt-BR catalog filled (996 strings and the unit names; path is "demarcador"): 2,936 of 2,939 strings by `i18n-report`, pt-BR to full 8–12 → 1–2 h; Russian (1,855) listed, eight languages beyond the twelve | | 2026-11-01 | minor | Initial Hungarian interface: 1,906 entries, four context captions, native review, bundled-font glyph coverage and wide/narrow flyout regressions | | 2026-10-11 | minor | Localized Japanese print, import and table controls; checked Japanese and English renders | | 2026-10-15 | minor | Swedish (`sv`) interface added | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cb57677a2..193d3ae9a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Text Frame Options layout, Preview and Paragraph Spacing Limit; Swatches rows show InDesign's type columns; Keyboard Shortcuts on craft-keymap's shared dialog, with shortcut sets; Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (pt-BR interface ~100%, Localization unchanged at 45%; Text Frame Options layout, Preview and Paragraph Spacing Limit; Swatches rows show InDesign's type columns; Keyboard Shortcuts on craft-keymap's shared dialog, with shortcut sets; Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -64,7 +64,7 @@ they receive, setting type exactly, and basic editing that never fails. | UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165) failing | [ui-parity.md](ui-parity.md) | | Stability | 10% | 45% | 60–100 | Never-crash rules and last-resort guards; startup failures on some GPUs and machines (#334, #273, #220, #192, #167, #166); no CI runs the tests on pull requests | [gaps.md](gaps.md) | | Performance | 5% | 65% | 40–80 | 300k characters compose in ~38 ms warm; typing repaints in < 1 ms (damage regions); no incremental composition; nothing benchmarked against InDesign; no large real documents | [gaps.md](gaps.md) | -| Localization | 3% | 45% | 120–180 | 9 translations beyond English; Japanese 95%, most others 83–85%, pt-BR 61%; Hindi, Indonesian, Korean, Vietnamese none | [localization-parity.md](localization-parity.md) | +| Localization | 3% | 45% | 120–180 | 15 translations beyond English; Japanese 95%, most others 83–85% (2026-10-10), pt-BR ~100% (2026-11-04); Hindi, Indonesian, Korean, Vietnamese none | [localization-parity.md](localization-parity.md) | | Platforms | 3% | 55% | 40–80 | macOS is where it's tested; Windows and Linux users hit basic failures; web and FreeBSD builds exist | [hardware-parity.md](hardware-parity.md) | | Hardware | 2% | 45% | 20–40 | Canvas rasterizes on the CPU (vello_cpu, multithreaded); GPU only presents; printing uses shared CUPS/Windows PDF spoolers (no PostScript; physical output needs validation) | [hardware-parity.md](hardware-parity.md) | | Ecosystem (scripting, plug-ins) | 2% | 15% | 150–250 | Own command/script/MCP API is strong; no ExtendScript/UXP DOM, so InDesign scripts don't run; no plug-ins | [gaps.md](gaps.md) | @@ -288,7 +288,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. -- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. +- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. Anchored text frames (also nested, and in groups, footnotes and cells) keep stories of their own when copied, pasted (also into another document, with the graphics they place), duplicated or released, and take their stories along when deleted. - Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects with the Selection tools (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), drawn highlighted, moved together by dragging or the arrow keys, deleted with Delete, and placed exactly with the X or Y field of the Control and Properties panels; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Column guides: with the Selection tool, drag an interior column guide to move its gutter (both guides together, keeping its width, stopped by the neighbouring guides and the margins), snapping to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels; one undo step. Refused while Lock Column Guides is on (the default). A document page takes the result as its own columns; on a parent page it reaches the pages that follow it. Objects stay where they are (there is no persistent Layout Adjustment setting yet). `layout.moveColumnGuide`. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. @@ -473,6 +473,7 @@ Task notes that headed ROADMAP.md: | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | Localization evidence: 15 translations beyond English, pt-BR ~100%; readiness and hours unchanged | | 2026-10-19 | minor | Output assessment records visible unembedded font detection in PDF/X-4 export and `validate-pdf`, the invisible-text exemption, incomplete-check reporting at the operation and nesting-depth limits, and remaining placed-PDF font repair (#909); readiness and hours unchanged | | 2026-10-13 | minor | `.indd` and `.indt` open through storytold/indd-utils; INDD remaining work 10–30 h (real-file corpus); readiness unchanged until it is measured | | 2026-10-12 | minor | Text threads by hand; stage note and distance to alpha follow the gate (5–7 h, #165); percentages unchanged |