mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-11 21:02:45 +00:00
Merge main into release for DesignCraft v0.20.0
This commit is contained in:
commit
7e2a123494
61 files changed
+4343
-202
No files matched your search
Generated
+21
-21
@@ -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",
|
||||
]
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+5
-3
@@ -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.
|
||||
|
||||
@@ -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<name>`, `\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<Vec<Range<usize>>, 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<Range<usize>>, 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"(?<x>a\g<x>\g<x>)", r"(?<x>a|b\g<x>)", 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"(?<x>a)\k<x>", "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
|
||||
|
||||
@@ -116,7 +116,10 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, 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());
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<StoryId, Arc<Story>> {
|
||||
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<Arc<Story>>) {
|
||||
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<Arc<Story>>,
|
||||
assets: Option<&Document>,
|
||||
path: &mut Vec<StoryId>,
|
||||
copies: &mut HashMap<StoryId, StoryId>,
|
||||
) {
|
||||
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<ItemId, StoryId> = HashMap::new();
|
||||
let mut empty: HashMap<StoryId, StoryId> = 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<ItemId> {
|
||||
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<StoryId> = 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<StoryId> =
|
||||
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<StoryId> = d.stories.keys().copied().collect();
|
||||
d.adopt_anchored(&mut copy, &|id| stories.get(&id).cloned());
|
||||
let added: Vec<StoryId> = 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<usize> = 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::<HashSet<_>>().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<String> = 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<String> = 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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String>, loaded: Option<Fingerprint>) {
|
||||
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
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<Fingerprint>,
|
||||
#[serde(skip)]
|
||||
pub data: Arc<Vec<u8>>,
|
||||
/// Pixel size for raster images.
|
||||
|
||||
@@ -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<Rect> = 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]
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -229,6 +229,13 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<String> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.check().unwrap();
|
||||
designcraft_format::load(&designcraft_format::save(d).unwrap()).unwrap();
|
||||
let stories: Vec<StoryId> = 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<String> = 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<Option<Arc<Vec<u8>>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<designcraft_doc::Fingerprint> {
|
||||
fingerprint_of(Path::new(path))
|
||||
|
||||
@@ -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<usize> = 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 <<Name>> b <<x>>"), [(2, 10, "Name".to_string()), (13, 18, "x".to_string())]);
|
||||
assert_eq!(found("<<>> <<a<b>> <<ok>>"), [(13, 19, "ok".to_string())]);
|
||||
assert_eq!(found("<<<<a>>"), [(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::*;
|
||||
|
||||
@@ -54,10 +54,44 @@ fn copy_text(s: &mut Session) -> Result<Option<String>> {
|
||||
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<designcraft_doc::AssetId> =
|
||||
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::AssetId> = 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<String>, 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<String>, 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<String> {
|
||||
/// 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<Value> {
|
||||
/// `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<Value> {
|
||||
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<CommandSpec> {
|
||||
// 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"));
|
||||
|
||||
@@ -90,7 +90,17 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => 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<u8>, String, Option<String>)> {
|
||||
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<u8>, String, Option<String>, Option<designcraft_doc::Fingerprint>)> {
|
||||
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<u8> = 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<Option<(f64, f64)>> {
|
||||
@@ -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<Value> {
|
||||
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();
|
||||
|
||||
@@ -90,7 +90,7 @@ fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result<Option<Documen
|
||||
let mut variant = (**asset).clone();
|
||||
variant.id = designcraft_doc::AssetId(out.alloc());
|
||||
variant.data = std::sync::Arc::new(bytes);
|
||||
variant.link = None;
|
||||
variant.set_link(None, None);
|
||||
let aid = variant.id;
|
||||
out.assets.insert(aid, std::sync::Arc::new(variant));
|
||||
variants.insert(id, aid);
|
||||
@@ -140,7 +140,7 @@ pub(crate) fn import_idml_confined(bytes: &[u8], package: Option<&std::path::Pat
|
||||
let read = |link: &str| -> Option<Vec<u8>> {
|
||||
#[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<Vec<u8>> {
|
||||
#[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<u8>)> {
|
||||
fn read_packaged_link(link: &str, package: Option<&std::path::Path>) -> Option<(String, Vec<u8>, Option<designcraft_doc::Fingerprint>)> {
|
||||
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();
|
||||
}
|
||||
|
||||
+189
-35
@@ -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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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<Fingerprint>, bytes: &[u8]) -> Option<Fingerprint> {
|
||||
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<Fingerprint>) {
|
||||
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<Shown<'a>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Vec<Value>> {
|
||||
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<Document>, 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<Vec<Value>> {
|
||||
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<Value> = 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<Value> = 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<Path>) -> Result<(Vec<u8>, (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<Arc<designcraft_doc::Asset>> {
|
||||
// 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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
let missing_only = p.get("missingOnly").and_then(Value::as_bool).unwrap_or(dir.is_some() && ext.is_none());
|
||||
let only: Option<Vec<u64>> = 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<Value> {
|
||||
})?;
|
||||
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<Value> {
|
||||
(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<Value> {
|
||||
}
|
||||
Ok(json!({"updated": n, "warnings": warnings}))
|
||||
});
|
||||
s.image_link_checks.clear();
|
||||
s.link_checks.images.clear();
|
||||
r
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
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<Value> {
|
||||
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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40"/></svg>"##).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);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
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<usize> = text.match_indices(target).map(|(pos, _)| pos).collect();
|
||||
at.reverse();
|
||||
at
|
||||
};
|
||||
let target = var_char(i).unwrap_or(' ').to_string();
|
||||
let mut occurrences: Vec<usize> = 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
+30
-12
@@ -392,6 +392,10 @@ pub struct Session {
|
||||
active: Option<usize>,
|
||||
/// 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<Cache>,
|
||||
pub(crate) tool: Box<dyn designcraft_tools::Tool>,
|
||||
@@ -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<std::path::PathBuf>,
|
||||
/// Copied text with its formatting (a frameless story slice) and its plain text.
|
||||
pub text_clipboard: Option<(Arc<designcraft_doc::Story>, 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<designcraft_doc::Story>, String, Arc<designcraft_doc::Document>)>,
|
||||
/// Requests for the UI (dialogs, view changes) produced by tools/commands.
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// 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<u64>)>,
|
||||
/// 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<String, (designcraft_doc::Fingerprint, String)>,
|
||||
/// 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();
|
||||
|
||||
+140
-14
@@ -75,8 +75,8 @@ fn step_of(v: &Value) -> Result<Step, String> {
|
||||
Ok(Step { command, params })
|
||||
}
|
||||
|
||||
/// The value a reference (`N.path`, `last.path`) points at.
|
||||
fn lookup(r: &str, results: &[Value]) -> Result<Value, String> {
|
||||
/// 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<Value, String> {
|
||||
} else {
|
||||
head.parse::<usize>().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::<usize>()) {
|
||||
(Value::Array(a), Ok(i)) => a.get(i).cloned(),
|
||||
(Value::Object(o), _) => o.get(p).cloned(),
|
||||
v = match (v, p.parse::<usize>()) {
|
||||
(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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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::<Result<_, _>>()?),
|
||||
Value::Object(o) => Value::Object(o.iter().map(|(k, v)| Ok((k.clone(), resolve(v, results)?))).collect::<Result<_, String>>()?),
|
||||
Value::Array(a) => Value::Array(a.iter().map(|v| resolve_in(v, results, max_ref, budget)).collect::<Result<_, _>>()?),
|
||||
Value::Object(o) => {
|
||||
Value::Object(o.iter().map(|(k, v)| Ok((k.clone(), resolve_in(v, results, max_ref, budget)?))).collect::<Result<_, String>>()?)
|
||||
}
|
||||
v => v.clone(),
|
||||
})
|
||||
}
|
||||
@@ -182,17 +238,58 @@ pub fn run(steps: &[Step], mut exec: impl FnMut(&str, Value) -> Result<Value, St
|
||||
Report { results, failed: None }
|
||||
}
|
||||
|
||||
/// How deep scripts may run scripts (`script.run` is a command a step may run).
|
||||
pub const MAX_SCRIPT_DEPTH: usize = 8;
|
||||
/// Steps one outermost script may run, the scripts it runs included: a script that runs itself
|
||||
/// (its text read back from a story, say) ends here rather than never.
|
||||
pub const MAX_SCRIPT_STEPS: usize = 100_000;
|
||||
|
||||
impl crate::Session {
|
||||
/// Run a script on this session (see [`crate::script`]).
|
||||
pub fn run_script(&mut self, steps: &[Step]) -> 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();
|
||||
|
||||
@@ -95,6 +95,7 @@ pub fn document(bytes: Vec<u8>, 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(),
|
||||
|
||||
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect width="1" height="1" fill="red"/></svg>"#.to_vec()),
|
||||
pixels: None,
|
||||
page: 0,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -40,6 +40,7 @@ fn shown(file: Vec<u8>, 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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<egui::Pos2>, frame_free: &mut bool) -> Result<Value, String> {
|
||||
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
|
||||
|
||||
@@ -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::<GuideDrag>(egui::Id::unique(GUIDE_DRAG)));
|
||||
|
||||
@@ -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<Pos2>) -> Option<designcraft_doc::ItemId> {
|
||||
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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<egui::epaint::ClippedShape> {
|
||||
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<Rect> = (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() {
|
||||
|
||||
@@ -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} — انقر للإدراج عند مؤشر النص"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}グリフ — クリックでテキストカーソル位置に挿入"),
|
||||
|
||||
@@ -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} — დაწკაპეთ ტექსტის კურსორთან ჩასასმელად"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+1056
-12
File diff suppressed because it is too large.
Load diff
@@ -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} — щелкните, чтобы вставить в позиции текстового курсора"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)", "(文本颜色)"]),
|
||||
(
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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} — клацніть, щоб вставити в позиції текстового курсора"),
|
||||
|
||||
@@ -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<u64, page_history::PageHistory>,
|
||||
/// 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<egui::Rect>,
|
||||
/// Window › Arrange › Split Window: two views of the document side by side.
|
||||
pub split: bool,
|
||||
|
||||
@@ -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<Document>, 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<Document>,
|
||||
pub(crate) cache: Arc<Cache>,
|
||||
pub(crate) document: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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<Vec<Value>>,
|
||||
/// 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<AtomicBool>,
|
||||
}
|
||||
|
||||
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<Document>,
|
||||
/// The checked rows; `None` when the check was stopped.
|
||||
rows: Option<Vec<Value>>,
|
||||
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<dyn FnMut(&Snapshot, &mut LinkListChecks, &AtomicBool) -> Option<Vec<Value>> + 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<Mutex<LinkListChecks>>;
|
||||
|
||||
/// 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<AtomicUsize>,
|
||||
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<Job>,
|
||||
rx: std::sync::mpsc::Receiver<Done>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn spawn(ctx: egui::Context, live: Arc<AtomicUsize>, pool: Pool) -> Option<Self> {
|
||||
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<AtomicUsize>, pool: Pool, mut run: Runner) -> Option<Self> {
|
||||
let (tx, jobs) = std::sync::mpsc::channel::<Job>();
|
||||
let (done_tx, rx) = std::sync::mpsc::channel::<Done>();
|
||||
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<Value> {
|
||||
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<Document>,
|
||||
rows: Vec<Value>,
|
||||
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<Document>,
|
||||
rows: Vec<Value>,
|
||||
}
|
||||
|
||||
struct InFlight {
|
||||
token: u64,
|
||||
key: Key,
|
||||
stop: Arc<AtomicBool>,
|
||||
/// When the check was told to stop.
|
||||
stopping: Option<Instant>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
type Spawn = Box<dyn Fn(egui::Context, Arc<AtomicUsize>, Pool) -> Option<Worker> + 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<dyn Fn(&Snapshot) -> Vec<Value> + Send + Sync>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct State {
|
||||
worker: Option<Worker>,
|
||||
/// When to try again after the worker couldn't be started or was found ended.
|
||||
retry_at: Option<Instant>,
|
||||
next_token: u64,
|
||||
in_flight: Option<InFlight>,
|
||||
/// The latest check of each document shown lately, by uid.
|
||||
checked: HashMap<u64, Checked>,
|
||||
structure: Option<Structure>,
|
||||
/// `structure` with the statuses of the check that ended at the given time (none: `None`):
|
||||
/// what the panel draws.
|
||||
shown: Option<(Option<Instant>, Arc<Vec<Value>>)>,
|
||||
live: Arc<AtomicUsize>,
|
||||
pool: Pool,
|
||||
#[cfg(test)]
|
||||
hooks: Option<Hooks>,
|
||||
}
|
||||
|
||||
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<Worker> {
|
||||
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<Value> {
|
||||
#[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<Vec<Value>> {
|
||||
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<String> {
|
||||
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<bool>, 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<Value> + Send + Sync + 'static,
|
||||
run: impl Fn(&Snapshot, &AtomicBool) -> Option<Vec<Value>> + Send + Sync + 'static,
|
||||
) -> (State, Arc<AtomicUsize>) {
|
||||
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<Job>, std::sync::mpsc::Sender<Done>) {
|
||||
let (tx, jobs) = std::sync::mpsc::channel::<Job>();
|
||||
let (done_tx, rx) = std::sync::mpsc::channel::<Done>();
|
||||
state.worker = Some(Worker { tx, rx });
|
||||
(jobs, done_tx)
|
||||
}
|
||||
|
||||
fn done(token: u64, key: Key, rows: Vec<Value>) -> 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::<Vec<_>>(), [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);
|
||||
}
|
||||
}
|
||||
@@ -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")];
|
||||
|
||||
@@ -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<Value>)> = 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<Value>)> = 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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
+3
-2
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user