diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37c71fc65..57eb9bb53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/Cargo.lock b/Cargo.lock index a9c1b23fd..138a753db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.6.0" +version = "0.7.1" dependencies = [ "arboard", "designcraft-engine", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -921,7 +921,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.6.0" +version = "0.7.1" dependencies = [ "kurbo", "moxcms", @@ -933,7 +933,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-doc", @@ -951,7 +951,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-geom", @@ -963,7 +963,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.6.0" +version = "0.7.1" dependencies = [ "caseless", "craft-print", @@ -998,7 +998,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1007,7 +1007,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.6.0" +version = "0.7.1" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -1019,7 +1019,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-doc", "serde", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.6.0" +version = "0.7.1" dependencies = [ "flo_curves", "kurbo", @@ -1043,7 +1043,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1056,7 +1056,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-fonts", @@ -1067,7 +1067,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1102,7 +1102,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1120,7 +1120,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-doc", "quick-xml 0.38.4", @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1141,7 +1141,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.6.0" +version = "0.7.1" dependencies = [ "craft-ui", "designcraft-color", @@ -1159,6 +1159,7 @@ dependencies = [ "regex", "serde", "serde_json", + "tempfile", "toml", "unicode-bidi", "unicode-bidi-mirroring", @@ -1168,7 +1169,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.6.0" +version = "0.7.1" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5519,8 +5520,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7" dependencies = [ "ahash", "android-activity", @@ -5688,7 +5688,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.6.0" +version = "0.7.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 4fcbfd625..b08a82fc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.7.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -97,3 +97,7 @@ opt-level = 3 lto = "thin" codegen-units = 16 debug = 0 + +# Opt-in cancelable macOS termination on the pinned winit 0.30 line. +[patch.crates-io] +winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } diff --git a/README.md b/README.md index 39fbe7100..0ec6540ba 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ cargo run --release -p designcraft -- --sample # open the sample mag cargo run --release -p designcraft -- --sample --control 7979 # + JSON control channel cargo run --release -p designcraft-cli -- run --sample --all-pages out/ # headless: render every page to PNG cargo run --release -p designcraft-cli -- commands # list every command +cargo run --release -p designcraft-cli -- script --help # command script syntax (docs/agents.md) cargo xtask ci # fmt, clippy, tests, assets, layering, wasm ``` diff --git a/ROADMAP.md b/ROADMAP.md index 69c49ed1f..abcd4990b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,7 +135,33 @@ Newest first. `text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No readiness percentage change. +- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. +- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. +- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. +- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. +- **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change. +- **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change. +- **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change. +- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change. +- **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change. +- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. +- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. +- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Hyphenation Zone applies to the Single-line Composer only, as in InDesign. With the default Paragraph Composer, ragged text hyphenates where InDesign does (aper-natem), giving the same line breaks. No readiness percentage change. +- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap per object, tints included) and / (Apply None), as `color.*` commands; swap and default are one undo step (`object.swapFillStroke`, `object.defaultFillStroke`); the Color panel follows the proxy's fill/stroke choice. No readiness percentage change. +- **2026-10-11** · Corner Options keep curved sides curved and round every corner point, including where an arc meets a line (a Pathfinder quarter circle with rounded corners stayed a triangle). No readiness percentage change. +- **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. +- **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. +- **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. +- **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. +- **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change. +- **2026-10-12** · Double-clicking the Fill or Stroke box in the Tools panel opens the Color Picker with the selection's colour, as in InDesign (it opened the Swatches panel). No readiness percentage change. +- **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change. +- **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change. +- **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. +- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. +- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. - **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. - **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index b1819c357..dd62cd91d 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -38,6 +38,37 @@ use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; +/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. +const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] + +Runs a command script from FILE (stdin when FILE is `-` or missing), headless or, with +--connect PORT, in the running app (designcraft --control PORT). + +Script text, one step per line: + command.id {"param": 1} also `command.id={json}`, or `command.id` alone + {"command": "command.id", "params": {...}} JSON lines, or a JSON array of them + # comment, // comment skipped, like blank lines + UTF-8 with or without a byte order mark; LF or CRLF line endings. + +References to earlier results (in string parameters): + "$N.path" the value at `path` in the result of step N. N is the 0-based step index: + comments and blank lines are not steps. `$last` is the previous step. + Paths go through result fields and array indices: "$1.story", "$3.ids.0". + "${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}". + +Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab. +Most commands ignore parameters they do not know instead of rejecting them: check names +with `designcraft-cli describe COMMAND`. + +Options: + --in FILE / --sample open a document (.designcraft or .idml) or the sample first + --save OUT save as .designcraft after the script succeeds + --export OUT export after the script succeeds (.pdf, .idml, .epub, .png, .jpg); repeatable + --keep-going run every step, recording failures as {"error": ...} results + +Prints {"completed": n, "results": [...]}, plus failedIndex / failedCommand / error and a +non-zero exit when a step fails."#; + /// The links line under the usage block. fn usage_footer() -> String { format!( @@ -281,7 +312,7 @@ fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), Str .render_page(&st.doc, &s.cache, page, scale, true, &designcraft_render::RenderOptions { printing_only: true, ..Default::default() }) .ok_or("no such page")?; let bytes = if out.ends_with(".jpg") || out.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; - std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs); Ok(()) } @@ -335,6 +366,10 @@ fn script(args: &[String]) -> Result<(), String> { "--save" => save = Some(val()?), "--export" => export.push(val()?), "--keep-going" => keep_going = true, + "--help" | "-h" => { + outln!("{SCRIPT_HELP}"); + return Ok(()); + } f if !f.starts_with("--") && file.is_none() => file = Some(f.to_string()), other => return Err(format!("unknown script option `{other}`")), } diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 1a06d13b0..92f68ab56 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -87,6 +87,21 @@ fn help_goes_to_stdout_and_unknown_args_to_stderr() { assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage: designcraft-cli")); } +/// `script --help` was "unknown script option `--help`"; the line syntax was only in +/// `describe script.run` (#654). +#[test] +fn script_help_documents_the_syntax() { + for flag in ["--help", "-h"] { + let out = cli().args(["script", flag]).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr)); + assert!(stdout.starts_with("usage: designcraft-cli script"), "{flag}: {stdout:?}"); + for needle in ["0-based", "$1.story", "$3.ids.0", "\\u2028", "ignore"] { + assert!(stdout.contains(needle), "{flag}: no {needle:?} in {stdout:?}"); + } + } +} + /// `designcraft-cli commands | head -1` panicked with "failed printing to /// stdout: Broken pipe (os error 32)" and exit status 101. #[test] @@ -122,3 +137,35 @@ fn run_export_options_come_before_their_export() { assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}"); let _ = std::fs::remove_dir_all(&dir); } + +#[cfg(unix)] +#[test] +fn raster_export_preserves_an_existing_reader_and_private_symlink_target() { + use std::io::Read; + use std::os::unix::fs::{PermissionsExt, symlink}; + struct Fixture(std::path::PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-cli-atomic-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let fixture = Fixture(dir); + let target = fixture.0.join(format!("{}.png", "a".repeat(243))); + let old = b"previous private export"; + std::fs::write(&target, old).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap(); + let alias = fixture.0.join("linked.png"); + symlink(target.file_name().unwrap(), &alias).unwrap(); + let mut reader = std::fs::File::open(&target).unwrap(); + let out = cli().args(["run", "--sample", "--scale", "0.1", "--export", alias.to_str().unwrap()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let mut observed = Vec::new(); + reader.read_to_end(&mut observed).unwrap(); + assert!(observed == old, "CLI export changed bytes under an existing reader"); + assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); +} diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 75767deaa..0f08ae8ed 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -32,4 +32,6 @@ arboard = { workspace = true } winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(target_os = "macos")'.dependencies] +# The same winit as eframe; only the native termination opt-in is used. +winit = { version = "0.30", default-features = false } muda = { version = "0.21", default-features = false } diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 2996d4fb0..2ac607bb8 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -64,10 +64,33 @@ impl eframe::App for App { if let Some(s) = self.startup.as_mut() { s.presented(); } + if let Err(e) = preserve_documents_on_exit(&mut self.app) { + log::error!("Couldn't preserve documents on exit: {e}"); + eprintln!("designcraft: couldn't preserve documents on exit: {e}"); + } save_prefs(&self.app); } } +/// Orderly OS termination can bypass egui's cancelable close event. Finish only a complete, +/// current quit plan; otherwise preserve every live document, including edits after the timer. +fn preserve_documents_on_exit(app: &mut DesignApp) -> Result<(), String> { + app.finish_confirmed_quit()?; + if app.session.documents().is_empty() { + return Ok(()); + } + if app.session.recovery_dir.is_none() { + return if app.session.documents().iter().any(|d| d.is_dirty()) { + Err("Recovery is unavailable; unsaved documents could not be preserved on exit".into()) + } else { + Ok(()) + }; + } + // Also discard stale recovery for clean documents (for example, Undo back to the saved + // state after the last timer tick). Never choose a fallback recovery path here. + app.run("file.recovery.save", serde_json::json!({})).map(|_| ()) +} + /// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos). fn no_prefs() -> bool { std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() @@ -186,7 +209,7 @@ fn services() -> Services { })), pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), - write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())), ..Default::default() } @@ -277,6 +300,15 @@ fn main() -> eframe::Result { b.with_x11(); })); } + // AppKit Dock Quit otherwise terminates before egui can cancel the close. Route it through + // ordinary CloseRequested events, preserving the same transactional Save/Don't Save flow. + #[cfg(target_os = "macos")] + { + options.event_loop_builder = Some(Box::new(|builder| { + use winit::platform::macos::EventLoopBuilderExtMacOS; + builder.with_close_requested_on_terminate(true); + })); + } eframe::run_native( "DesignCraft", options, @@ -354,3 +386,168 @@ mod tests { assert!(documents.iter().any(|filter| filter.extensions.contains(&"designcraft"))); } } + +#[cfg(all(test, unix))] +mod atomic_writer_tests { + #[test] + fn desktop_write_preserves_an_existing_reader_and_private_symlink_target() { + use std::io::Read; + use std::os::unix::fs::{PermissionsExt, symlink}; + struct Fixture(std::path::PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-desktop-atomic-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let fixture = Fixture(dir); + let target = fixture.0.join(format!("{}.png", "a".repeat(243))); + let old = b"previous private export"; + std::fs::write(&target, old).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap(); + let alias = fixture.0.join("linked.png"); + symlink(target.file_name().unwrap(), &alias).unwrap(); + let mut reader = std::fs::File::open(&target).unwrap(); + let mut write = super::services().write.unwrap(); + write(alias.to_str().unwrap(), b"complete new export").unwrap(); + let mut observed = Vec::new(); + reader.read_to_end(&mut observed).unwrap(); + assert!(observed == old, "desktop export changed bytes under an existing reader"); + assert_eq!(std::fs::read(&target).unwrap(), b"complete new export"); + assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); + } +} + +#[cfg(test)] +mod exit_recovery_tests { + use super::*; + use serde_json::json; + + fn dirty_app(label: &str) -> (DesignApp, std::path::PathBuf) { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + let uid = app.session.active().unwrap().uid; + let dir = std::env::temp_dir().join(format!("designcraft-exit-{label}-{}-{uid}", std::process::id())); + assert!(!dir.exists()); + app.session.recovery_dir = Some(dir.clone()); + (app, dir) + } + + fn close_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) { + let mut raw = egui::RawInput::default(); + if close { + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + } + let mut out = ctx.run_ui(raw, |ui| app.logic(ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn exit_recovery_preserves_latest_dirty_document_without_mutating_it() { + let (mut app, dir) = dirty_app("latest"); + app.run("file.recovery.save", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + let d = app.session.active().unwrap(); + let (uid, revision, saved_revision, doc, selection, undo, redo) = + (d.uid, d.revision, d.saved_revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len()); + preserve_documents_on_exit(&mut app).unwrap(); + let current = app.session.active().unwrap(); + assert_eq!( + (current.uid, current.revision, current.saved_revision, ¤t.selection.items, current.history.undo.len(), current.history.redo.len()), + (uid, revision, saved_revision, &selection, undo, redo) + ); + assert!(std::sync::Arc::ptr_eq(¤t.doc, &doc)); + assert!(current.is_dirty()); + let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_removes_stale_data_after_undo_to_saved_state() { + let (mut app, dir) = dirty_app("clean"); + let uid = app.session.active().unwrap().uid; + app.run("file.recovery.save", json!({})).unwrap(); + let entry = dir.join(format!("{uid}.designcraft")); + assert!(entry.exists()); + app.run("edit.undo", json!({})).unwrap(); + assert!(!app.session.active().unwrap().is_dirty()); + let doc = app.session.active().unwrap().doc.clone(); + preserve_documents_on_exit(&mut app).unwrap(); + assert!(!entry.exists(), "clean exit must not resurrect the pre-Undo edits"); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_reports_unavailable_or_failed_storage_without_changing_documents() { + let (mut app, dir) = dirty_app("failure"); + let doc = app.session.active().unwrap().doc.clone(); + app.session.recovery_dir = None; + assert!(preserve_documents_on_exit(&mut app).unwrap_err().contains("unavailable")); + std::fs::create_dir(&dir).unwrap(); + let blocked = dir.join("not-a-directory"); + std::fs::write(&blocked, b"preserve this file").unwrap(); + app.session.recovery_dir = Some(blocked.clone()); + assert!(preserve_documents_on_exit(&mut app).is_err()); + assert_eq!(std::fs::read(&blocked).unwrap(), b"preserve this file"); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + assert!(app.session.active().unwrap().is_dirty()); + assert_eq!(app.session.documents().len(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_respects_complete_incomplete_stale_and_new_document_quit_plans() { + for case in ["complete", "incomplete", "stale", "new"] { + let (mut app, dir) = dirty_app(case); + if case == "incomplete" { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + app.run("file.recovery.save", json!({})).unwrap(); + let first_uid = app.session.documents()[0].uid; + let ctx = egui::Context::default(); + close_frame(&mut app, &ctx, true); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + designcraft_ui_egui::dialogs::confirm(&mut app).unwrap(); + close_frame(&mut app, &ctx, false); + if case == "stale" { + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + } else if case == "new" { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + let active = app.session.active().unwrap().uid; + let before: Vec<_> = app + .session + .documents() + .iter() + .map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len())) + .collect(); + preserve_documents_on_exit(&mut app).unwrap(); + if case == "complete" { + assert!(app.session.documents().is_empty()); + assert!(!dir.join(format!("{first_uid}.designcraft")).exists(), "accepted Don't Save must not be recovered"); + } else { + assert_eq!(app.session.documents().len(), before.len(), "{case}"); + assert_eq!(app.session.active().unwrap().uid, active, "{case}"); + for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) { + assert_eq!( + (d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()), + (uid, revision, &selection, undo, redo), + "{case}" + ); + assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}"); + let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap(), "{case}"); + } + } + std::fs::remove_dir_all(dir).unwrap(); + } + } +} diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index 8bf75b159..f51db197a 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -40,6 +40,8 @@ impl NativeMenu { // Our own About splash (with the community links) instead of the stock panel. let about = MenuItem::with_id("dc-about", "About DesignCraft", true, None); let discord = MenuItem::with_id("dc-discord", "Join the ArtCraft Discord…", true, None); + // Not the predefined Quit: it terminates the app at once, past the unsaved-changes prompt. + let quit = MenuItem::with_id("dc-quit", designcraft_ui_egui::i18n::tr(&app.ui.language, "Quit DesignCraft"), true, accel("Cmd+Q")); let _ = app_menu.append_items(&[ &about, &discord, @@ -50,10 +52,11 @@ impl NativeMenu { &PredefinedMenuItem::hide_others(None), &PredefinedMenuItem::show_all(None), &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), + &quit, ]); items.insert("dc-about".into(), ("help.about".into(), serde_json::Value::Null, Handle::Plain(about))); items.insert("dc-discord".into(), ("help.discord".into(), serde_json::Value::Null, Handle::Plain(discord))); + items.insert("dc-quit".into(), ("app.quit".into(), serde_json::Value::Null, Handle::Plain(quit))); let _ = menu.append(&app_menu); for (title, entries) in menus::menu_tree() { let sub = Submenu::new(designcraft_ui_egui::i18n::tr(&app.ui.language, title), true); diff --git a/apps/designcraft/src/profile.rs b/apps/designcraft/src/profile.rs index c3da12b08..3e90879e3 100644 --- a/apps/designcraft/src/profile.rs +++ b/apps/designcraft/src/profile.rs @@ -22,7 +22,19 @@ fn config_dir_with(os: &str, env: impl Fn(&str) -> Option) -> Option

Option { - recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir) + recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir).and_then(own_run) +} + +/// This run's folder in the recovery folder `root`. A second running DesignCraft has its own, so +/// neither writes over the other's documents nor takes them for a crash's. +fn own_run(root: PathBuf) -> Option { + match designcraft_engine::recovery::run_dir(&root) { + Ok(dir) => Some(dir), + Err(e) => { + log::warn!("recovery is unavailable because this run cannot be isolated: {e}"); + None + } + } } fn recovery_dir_with(root: Option, default: impl FnOnce() -> Option) -> Option { @@ -68,4 +80,29 @@ mod tests { assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into())); assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into())); } + + #[test] + fn a_run_has_its_own_recovery_folder_when_one_can_be_made() { + let dir = std::env::temp_dir().join(format!("designcraft-profile-run-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let root = dir.join("Recovery"); + assert_eq!(own_run(root.clone()).as_deref().and_then(|dir| dir.parent()), Some(root.as_path())); + // Failure must not fall back to the shared root. + let blocked = dir.join("blocked"); + std::fs::write(&blocked, "a file where the folder should be").unwrap(); + assert_eq!(own_run(blocked.join("Recovery")), None, "isolation failure must not select the shared root"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_exhausted_run_namespace_never_falls_back_to_shared_recovery() { + let root = std::env::temp_dir().join(format!("designcraft-profile-exhausted-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + for n in 0..64 { + std::fs::create_dir(root.join(format!("run-{}-{n}", std::process::id()))).unwrap(); + } + assert_eq!(own_run(root.clone()), None, "a shared root would restore inter-process UID collisions"); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index c5e30a98d..4da4f2d84 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -104,9 +104,12 @@ impl Gradient { out } pub fn reverse(&mut self) { + // A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors. + let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect(); self.stops.reverse(); - for s in &mut self.stops { + for (s, m) in self.stops.iter_mut().zip(mids.into_iter().chain([0.5])) { s.offset = 1.0 - s.offset; + s.midpoint = m; } } pub fn sort(&mut self) { @@ -219,8 +222,24 @@ mod tests { #[test] fn reverse_stops() { let mut g = Gradient::default(); + g.stops[0].midpoint = 0.25; g.reverse(); assert_eq!(g.stops[0].color.to_hex(), "#000000"); assert_eq!(g.stops[0].offset, 0.0); + // The midpoint moves to its reversed segment and mirrors. + assert!((g.sample(0.75).0.to_rgb()[0] - 0.5).abs() < 1e-5); + assert_eq!(g.stops.iter().map(|s| s.midpoint).collect::>(), [0.75, 0.5]); + + // With several stops, the reversed gradient is the original mirrored. + let mut g = Gradient::default(); + g.stops[0].midpoint = 0.3; + g.stops.insert(1, GradientStop { offset: 0.4, color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.5, midpoint: 0.8 }); + let original = g.clone(); + g.reverse(); + for t in [0.1, 0.3, 0.5, 0.7, 0.9] { + let ((c, o), (want_c, want_o)) = (g.sample(t), original.sample(1.0 - t)); + assert!(c.to_rgb().iter().zip(want_c.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "colour at {t}"); + assert!((o - want_o).abs() < 1e-5, "opacity at {t}"); + } } } diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 6d6b1b980..8aabbf0f1 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -13,7 +13,8 @@ use designcraft_doc::{Document, Item, Story, StoryId, Styles, WrapMode}; use crate::vars::RunningIndex; use crate::{ComposeOptions, ComposedStory}; -type Key = (StoryId, Option); +/// A story, composed for one absolute page (parent-page items) or for its own frames (`None`). +type Key = (StoryId, Option); struct Entry { sig: Vec, @@ -55,11 +56,23 @@ impl Cache { Self::default() } - /// The composed story, from cache or freshly composed. - pub fn get(&self, doc: &Document, sid: StoryId, page_name: Option<&str>) -> Arc { + /// The composed story, from cache or freshly composed. `page` is the absolute page a + /// parent-page item is shown on (its page number markers and variables are that page's), or + /// `None` for a story's own frames. Pages are identified by index, not name: names repeat when + /// sections restart their numbering. + pub fn get(&self, doc: &Document, sid: StoryId, page: Option) -> Arc { let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) }; let (mut sig, keep_items) = signature(doc, story); - let key = (sid, page_name.map(str::to_string)); + let key = (sid, page); + // The key holds the page's index, which a renumbering doesn't change: its name (section + // start number, style, prefix) is part of the signature. + let page_name = page.map(|p| doc.page_name(p)); + if let Some(name) = &page_name { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + name.hash(&mut h); + sig.push(h.finish() as usize); + } let with_vars = crate::vars::has_vars(story); let running = with_vars && crate::vars::has_running(doc, story); if with_vars { @@ -125,23 +138,20 @@ impl Cache { return e.out.clone(); } } - // Parent-page items are composed per page; variables need that page's index. - let page = match page_name { - Some(n) if with_vars => (0..doc.page_count()).find(|&i| doc.page_name(i) == n), - _ => None, - }; + // Parent-page items are composed per page: markers show its name, variables need its index. + let page = page.filter(|_| with_vars); let running = running.then(|| self.running_index(doc)); let out = Arc::new(crate::compose_story( doc, sid, - &ComposeOptions { page_name: page_name.map(str::to_string), page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, + &ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, )); let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); let stamp = g.1; if g.0.len() > 4096 { g.0.retain(|_, e| stamp - e.stamp < 64); } - let replaced = g.0.insert(key.clone(), Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp }); + let replaced = g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp }); drop(g); if key.1.is_none() && let Some(old) = replaced diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 92dd82f6d..4ed599b04 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -330,7 +330,8 @@ pub struct FrameSpec { impl FrameSpec { pub fn columns(&self) -> Vec { let a = self.area; - let n = self.opts.columns.max(1); + // Primary frames inherit the document limit (216), beyond the interactive frame editor's 40. + let n = self.opts.columns.clamp(1, 216); match self.opts.columns_kind { designcraft_doc::ColumnsKind::FixedWidth if self.opts.column_width > 0.0 => (0..n) .map(|i| { @@ -431,7 +432,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { path: None, ..tf.options.clone() }; - (Rect::new(0.0, 0.0, (len - pt.start).max(1.0), size * 1.6), opts) + let end = pt.end.map_or(len, |e| e.max(0.0).min(len)); + (Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts) } None => (item.text_area(), tf.options.clone()), }; @@ -1563,7 +1565,9 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { hyphen_penalty: 50.0 + 450.0 * pp.hyph_weight, hyphen_limit: pp.hyph_limit, ragged_stretch: base_size * 2.0, - hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone }, + // The zone is the Single-line Composer's (unjustified text only), as in InDesign: the + // Paragraph Composer weighs every break of the paragraph instead. + hyph_zone: if pp.align.is_justified() || pp.composer != Composer::SingleLine { 0.0 } else { pp.hyph_zone }, optical: pp.optical_margin, korean_char_breaks: pp.korean_char_breaks, } @@ -2438,6 +2442,18 @@ fn layout_line( // A default tab stop is a left tab. last_tab = Some((i, stop.as_ref().is_none_or(|t| t.align == TabAlign::Left))); } else if line[i].ch == story::RIGHT_INDENT_TAB { + // Use the first stop past the right margin, or the last stop at/before it. + // Choose the stop before checking its leader: an empty leader takes precedence too. + let right = x1 - tab_origin; + let stop = pp + .tabs + .iter() + .filter(|t| t.position > right) + .min_by(|a, b| a.position.total_cmp(&b.position)) + .or_else(|| pp.tabs.iter().filter(|t| t.position <= right).max_by(|a, b| a.position.total_cmp(&b.position))); + if let Some(t) = stop.filter(|t| !t.leader.is_empty()) { + leaders.push((i, t.leader.clone())); + } let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum(); line[i].adv = (measure - x - rest).max(0.0); last_tab = Some((i, false)); @@ -3563,6 +3579,51 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option { #[cfg(test)] mod tests; +/// Type on a path: between the composed line (x along it, y as composed) and the frame's path +/// space, as `path_glyphs` places the glyphs. +pub struct PathTextMap { + warp: designcraft_geom::warp::PathWarp, + start: f64, + /// Line y that sits on the path (the alignment), and the line's baseline. + on: f64, + baseline: f64, +} + +impl PathTextMap { + /// The map of `item` (type on a path) for its composed frame `ft`; None for other frames. + pub fn new(item: &designcraft_doc::Item, ft: &FrameText) -> Option { + let pt = item.text_frame()?.options.path.as_ref()?; + let warp = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), pt.flip); + let (on, baseline) = match ft.lines.first() { + Some(l) => ( + match pt.align { + designcraft_doc::PathAlign::Baseline => l.baseline, + designcraft_doc::PathAlign::Ascender => l.baseline - l.ascent, + designcraft_doc::PathAlign::Descender => l.baseline + l.descent, + designcraft_doc::PathAlign::Center => l.baseline - (l.ascent - l.descent) / 2.0, + }, + l.baseline, + ), + None => (0.0, 0.0), + }; + Some(PathTextMap { warp, start: pt.start, on, baseline }) + } + + /// A point of the composed line, on the path: along it by x, across it by y. + pub fn to_path(&self, p: Point) -> Point { + match self.warp.at(self.start + p.x) { + Some((q, a)) => q + designcraft_geom::Vec2::new(-a.sin(), a.cos()) * (p.y - self.on), + None => p, + } + } + + /// A point of the path space, on the composed line: how far along the path its nearest point + /// is, at the baseline (for hit testing). + pub fn to_line(&self, p: Point) -> Point { + Point::new(self.warp.nearest(p) - self.start, self.baseline) + } +} + /// Each visible glyph of a type-on-a-path frame placed on the path: its outline in frame space /// (grouped by run style). pub fn path_glyphs( @@ -3613,3 +3674,47 @@ pub fn path_glyphs( } runs } + +#[cfg(test)] +mod column_allocation_safety_tests { + use super::*; + + fn frame(kind: designcraft_doc::ColumnsKind) -> FrameSpec { + FrameSpec { + id: ItemId(1), + area: Rect::new(0., 0., 300., 100.), + opts: TextFrameOptions { columns: 217, columns_kind: kind, column_width: 20., gutter: 12., ..Default::default() }, + vertical: false, + exclusions: vec![], + page_name: None, + page: None, + grid: None, + left_page: false, + page_rect: None, + } + } + fn check(kind: designcraft_doc::ColumnsKind) { + let mut f = frame(kind); + // Fail safely at a small boundary before exercising hostile native options. + assert_eq!(f.columns().len(), 216); + f.opts.columns = u32::MAX; + assert_eq!(f.columns().len(), 216); + f.opts.columns = 0; + assert_eq!(f.columns().len(), 1); + f.opts.columns = 2; + let expected = if kind == designcraft_doc::ColumnsKind::FixedWidth { + vec![Rect::new(0., 0., 20., 100.), Rect::new(32., 0., 52., 100.)] + } else { + vec![Rect::new(0., 0., 144., 100.), Rect::new(156., 0., 300., 100.)] + }; + assert_eq!(f.columns(), expected); + } + #[test] + fn column_allocation_caps_equal_width_frame_count() { + check(designcraft_doc::ColumnsKind::FixedNumber); + } + #[test] + fn column_allocation_caps_fixed_width_frame_count() { + check(designcraft_doc::ColumnsKind::FixedWidth); + } +} diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 674c77560..ce8964bfe 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -523,11 +523,16 @@ fn hyphenation_zone_limits_ragged_hyphens() { let cs = compose_story(&d, sid, &ComposeOptions::default()); all_lines(&cs).iter().filter(|l| l.hyphenated).count() }; - for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] { - let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); - // A huge zone leaves only words that start a line (no space to break at) hyphenable. - assert!(free > 0 && zoned <= free && huge * 4 <= free, "{c:?}: {free} {zoned} {huge}"); - } + // Single-line Composer: a huge zone leaves only words that start a line (no space to break + // at) hyphenable. + let c = designcraft_doc::Composer::SingleLine; + let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); + assert!(free > 0 && zoned <= free && huge * 4 <= free, "{c:?}: {free} {zoned} {huge}"); + // The zone is the Single-line Composer's only (InDesign): the Paragraph Composer hyphenates + // the same whatever it is. + let c = designcraft_doc::Composer::Paragraph; + let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); + assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}"); } #[test] @@ -1028,6 +1033,125 @@ fn tab_leaders_fill_the_gap() { assert!(last_dot < num_x, "leaders stop before the page number"); } +#[test] +fn right_indent_tab_leaders_follow_stop_precedence_without_moving_text() { + let text = "Intro\u{e006}12"; + let cases = [ + ("last interior", 0.0, vec![(200.0, "."), (80.0, "-")], Some('.')), + ("first exterior", 0.0, vec![(380.0, "+"), (200.0, "-"), (320.0, ".")], Some('.')), + ("at the margin", 0.0, vec![(300.0, "."), (80.0, "-")], Some('.')), + ("no stops", 0.0, vec![], None), + ("empty exterior", 0.0, vec![(200.0, "."), (320.0, ""), (350.0, "+")], None), + ("empty interior", 0.0, vec![(80.0, "."), (200.0, "")], None), + ("indented margin", 36.0, vec![(260.0, "-"), (280.0, "."), (340.0, "+")], Some('.')), + ]; + for (name, right_indent, stops, expected) in cases { + let tabs = stops + .into_iter() + .map(|(position, leader)| designcraft_doc::TabStop { position, align: TabAlign::Right, leader: leader.into(), align_on: String::new() }) + .collect(); + let para = ParaAttrs { tabs: Some(tabs), left_indent: Some(12.0), right_indent: Some(right_indent), ..Default::default() }; + // Translation and left/right indents distinguish the tab origin from line/absolute x. + let (mut d, sid, _) = doc_with(text, Rect::new(72.0, 36.0, 372.0, 136.0), para); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset(), "{name}"); + assert_eq!(cs.frames[0].lines.len(), 1, "{name}"); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 5 && g.len > 0).unwrap(); + let leaders: Vec<_> = line.glyphs.iter().filter(|g| g.visible && g.len == 0).collect(); + assert_eq!(!leaders.is_empty(), expected.is_some(), "{name}"); + for g in leaders { + assert_eq!(g.gid, g.face.get().glyph_for(expected.unwrap()), "{name}"); + let width = g.face.get().advance(g.gid) * g.sx; + assert!(g.x >= tab.x - 0.01 && g.x + width <= tab.x + tab.adv + 0.01, "{name}: leader outside tab gap"); + assert_eq!(g.byte, tab.byte, "{name}"); + } + assert!((line.end_x - (372.0 - right_indent)).abs() < 0.01, "{name}: number stays at the right margin"); + // Removing only the leaders must leave every source glyph and the line itself unchanged. + for t in d.story_mut(sid).unwrap().paras[0].para.tabs.as_mut().unwrap() { + t.leader.clear(); + } + let plain = compose_story(&d, sid, &Default::default()); + assert_eq!(plain.frames[0].lines.len(), 1, "{name}"); + let plain_line = &plain.frames[0].lines[0]; + assert_eq!((&line.range, line.baseline, line.end_x), (&plain_line.range, plain_line.baseline, plain_line.end_x), "{name}"); + let source_positions = |l: &Line| { + l.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, g.len, g.gid, g.x, g.y, g.adv, g.sx, g.sy, g.style)).collect::>() + }; + assert_eq!(source_positions(line), source_positions(plain_line), "{name}"); + assert_eq!(d.stories[&sid].text, text, "{name}: source text is not expanded"); + } +} + +#[test] +fn right_indent_tab_leaders_use_the_tab_format_and_regular_tab_drawing() { + let mut rendered = Vec::new(); + for ch in [story::RIGHT_INDENT_TAB, '\t'] { + let text = format!("Intro{ch}12"); + let para = ParaAttrs { + tabs: Some(vec![designcraft_doc::TabStop { position: 300.0, align: TabAlign::Right, leader: ".-".into(), align_on: String::new() }]), + ..Default::default() + }; + let (mut d, sid, _) = doc_with(&text, Rect::new(72.0, 36.0, 372.0, 136.0), para); + d.story_mut(sid).unwrap().format_chars(5..5 + ch.len_utf8(), |f| { + f.over.font_family = Some("Inter".into()); + f.over.size = Some(18.0); + f.over.h_scale = Some(1.3); + f.over.v_scale = Some(0.8); + f.over.baseline_shift = Some(4.0); + f.over.fill_tint = Some(0.4); + }); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset()); + assert_eq!(cs.frames[0].lines.len(), 1); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 5 && g.len > 0).unwrap(); + let prefix = &line.glyphs[0]; + assert_ne!(tab.face.id(), prefix.face.id(), "tab uses its own font"); + assert_ne!(tab.style, prefix.style, "tab uses its own character formatting"); + assert_eq!(cs.styles[tab.style as usize].size, 18.0); + assert_eq!(cs.styles[tab.style as usize].fill_tint, 0.4); + let leaders: Vec<_> = line.glyphs.iter().filter(|g| g.visible && g.len == 0).collect(); + assert!(leaders.len() > 10); + assert_eq!(leaders.len() % 2, 0, "only complete patterns are drawn"); + for (i, g) in leaders.iter().enumerate() { + assert_eq!(g.gid, tab.face.get().glyph_for(if i % 2 == 0 { '.' } else { '-' })); + assert_eq!(g.face.id(), tab.face.id()); + assert_eq!((g.sx, g.sy, g.y, g.style, g.byte), (tab.sx, tab.sy, -4.0, tab.style, tab.byte)); + let width = g.face.get().advance(g.gid) * g.sx; + assert!(g.x >= tab.x - 0.01 && g.x + width <= tab.x + tab.adv + 0.01); + } + rendered.push(leaders.iter().map(|g| (g.gid, g.x, g.y, g.sx, g.sy, g.style)).collect::>()); + } + assert_eq!(rendered[0], rendered[1], "right-indent and equivalent regular right tabs use the same leader drawing"); +} + +#[test] +fn right_indent_tab_leaders_do_not_draw_when_the_gap_cannot_fit_a_pattern() { + let text = "A\u{e006}B"; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &Default::default()); + let line = &cs.frames[0].lines[0]; + let natural: f64 = line.glyphs.iter().filter(|g| g.visible).map(|g| g.adv).sum(); + let first = &line.glyphs[0]; + let pattern_width = first.face.get().advance(first.face.get().glyph_for('-')) * first.sx; + assert!(pattern_width > 0.01); + for gap in [0.0, pattern_width / 2.0] { + let para = ParaAttrs { + tabs: Some(vec![designcraft_doc::TabStop { position: 1.0, align: TabAlign::Left, leader: "-".into(), align_on: String::new() }]), + ..Default::default() + }; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, natural + gap, 100.0), para); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset()); + assert_eq!(cs.frames[0].lines.len(), 1); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 1 && g.len > 0).unwrap(); + assert!((tab.adv - gap).abs() < 1e-6); + assert!(line.glyphs.iter().all(|g| g.len > 0 || !g.visible), "a partial leader pattern must not overlap adjacent text"); + } +} + #[test] fn footnotes_sit_at_the_column_bottom_and_push_text() { // A short frame: without footnotes the text fills it; with two footnotes the body text @@ -2917,6 +3041,23 @@ fn cjk_fallback_follows_the_language() { assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone())); } +#[test] +fn malayalam_falls_back_to_a_font_that_forms_its_conjuncts() { + // Run against the installed fonts: on macOS the first file mapping Malayalam is Arial + // Unicode MS, which leaves ക്ഷ as three glyphs. + let db = designcraft_fonts::FontDb::global(); + if !["Malayalam Sangam MN", "Malayalam MN", "Nirmala UI", "Noto Sans Malayalam"].iter().any(|f| db.has_family(f)) { + return; + } + let text = "ക്ഷ"; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + d.settings.glyph_fallback = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let glyphs: Vec<_> = all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).map(|g| (g.face.family.clone(), g.gid)).collect(); + assert_eq!(glyphs.len(), 1, "one conjunct: {glyphs:?}"); + assert_ne!(glyphs[0].1, 0); +} + #[test] fn missing_glyphs_are_the_fonts_box_unless_fallback_is_on() { use designcraft_fonts::testing::font_with; diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 51e17abbb..7b2168e30 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -209,6 +209,7 @@ impl Document { if from == to { return Err(DocError::Invalid("cannot thread a frame to itself".into())); } + self.check_thread_links(from, to, false)?; let ts = self.frame_story(to)?; let fs = match self.frame_story(from)? { Some(s) => s, @@ -232,8 +233,10 @@ impl Document { let after = other.frames.split_off(pos_to); let before = std::mem::take(&mut other.frames); let (vertical, direction) = (other.vertical, other.direction); + let offset; { let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; + offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) }; // The story keeps its own direction, even when it was empty and takes the other's text. let own = (st.vertical, st.direction); st.append_story(other); @@ -244,6 +247,7 @@ impl Document { } st.rev += 1; } + self.remap_thread_story_refs(ts, fs, offset); for f in &after { if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { t.story = fs; @@ -274,8 +278,10 @@ impl Document { let ts = self.frame_story(to)?; let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to)); let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) }; + self.check_thread_links(from, to, true)?; if let Some(fs) = fs { self.stories.remove(&fs); + self.remap_thread_story_refs(fs, ts, 0); } let st = self.story_mut(ts).ok_or(DocError::NoStory(ts))?; st.frames.insert(0, from); @@ -288,6 +294,63 @@ impl Document { Ok(()) } + /// Story-external text addresses move with the incoming text when threading merges stories. + /// Linked children follow the surviving whole story, including for an empty removed parent; + /// their copied content stays unchanged until an explicit Update Link. + fn remap_thread_story_refs(&mut self, from: StoryId, to: StoryId, offset: usize) { + // The child's old snapshot is not a snapshot of the newly merged parent. Force it + // out of date even when the old and surviving revision numbers happen to coincide. + let stale_rev = self.story(to).map_or(0, |st| st.rev.wrapping_sub(1)); + for st in self.stories.values_mut() { + if st.link.is_some_and(|(parent, _)| parent == from) { + Arc::make_mut(st).link = Some((to, stale_rev)); + } + } + for link in &mut self.hyperlinks { + if let crate::HyperlinkSource::Text { story, start, end } = &mut link.source + && *story == from + { + *story = to; + *start = start.saturating_add(offset); + *end = end.saturating_add(offset); + } + } + for placeholder in &mut self.data_merge.placeholders { + if let crate::PlaceholderAnchor::Text { story, start, end } = &mut placeholder.anchor + && *story == from + { + *story = to; + *start = start.saturating_add(offset); + *end = end.saturating_add(offset); + } + } + if self.settings.primary_story == Some(from) { + self.settings.primary_story = Some(to); + } + } + + /// A linked child is replaced wholesale on update. Only empty, unlinked frame + /// extensions can join it without losing a link or newly appended body text. + fn check_thread_links(&self, from: ItemId, to: ItemId, keep_to: bool) -> Result<()> { + let fs = self.frame_story(from)?; + let ts = self.frame_story(to)?; + let first = fs.and_then(|id| self.story(id)); + let second = ts.and_then(|id| self.story(id)); + let incompatible = if keep_to { + first.is_some_and(|st| st.link.is_some()) || second.is_some_and(|st| st.link.is_some_and(|(parent, _)| Some(parent) == fs)) + } else { + second.is_some_and(|st| st.link.is_some()) + || first.is_some_and(|st| { + st.link.is_some() + && second.is_some_and(|incoming| !incoming.text.is_empty() || st.link.is_some_and(|(parent, _)| parent == incoming.id)) + }) + }; + if incompatible { + return Err(DocError::Invalid("unlink the linked story before merging its text".into())); + } + Ok(()) + } + /// Can a user thread `from` → `to`? Both are text frames or empty frames (not lines), on /// document spreads or on the same parent spread, not in one story already, and no frame /// threads into `to` yet. [`Document::thread`] itself is less strict (import and autoflow). @@ -316,6 +379,23 @@ impl Document { if story(from).is_some() && story(from) == story(to) { return Err(DocError::Invalid("the frames are already in the same thread".into())); } + // Generated text is rebuilt from its source data. Merging body text into it would + // silently discard that text on the next update (endnotes update after every edit). + let generated = |sid| { + self.endnote_story == Some(sid) + || self.toc.as_ref().is_some_and(|t| t.story == sid) + || self.index.as_ref().is_some_and(|i| i.story == sid) + }; + let (fs, ts) = (story(from), story(to)); + if fs.is_some_and(generated) && ts.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty()) { + return Err(DocError::Invalid("cannot merge body text into a generated story".into())); + } + if ts.is_some_and(generated) && fs.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty() || st.frames != [from]) { + return Err(DocError::Invalid("a generated story can only take a new empty first frame".into())); + } + let keep_to = fs.is_none_or(|sid| self.story(sid).is_some_and(|st| st.text.is_empty() && st.frames == [from])) + && ts.and_then(|sid| self.story(sid)).is_some_and(|st| st.frames.first() == Some(&to)); + self.check_thread_links(from, to, keep_to)?; if self.prev_frame(to).is_some() { return Err(DocError::Invalid(format!("text already flows into {to} from another frame: break that thread first"))); } @@ -502,5 +582,11 @@ pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool { if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) { return true; } - designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner) + // Type on a path: the letters stand over the path, so a click on them hits it too. + let reach = if it.text_frame().is_some_and(|t| t.options.path.is_some()) { PATH_TYPE_REACH } else { 0.0 }; + designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol + reach, inner) } + +/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type, +/// either side (the type may sit on or under the path). +const PATH_TYPE_REACH: f64 = 12.0; diff --git a/crates/doc/src/ids.rs b/crates/doc/src/ids.rs index 3dbb94c44..fed2cb8fc 100644 --- a/crates/doc/src/ids.rs +++ b/crates/doc/src/ids.rs @@ -21,3 +21,24 @@ id_type!(SpreadId, "sp"); id_type!(PageId, "p"); id_type!(LayerId, "l"); id_type!(AssetId, "a"); + +/// Fresh story-local id. Keep monotonically increasing ids normally; a loaded MAX id must +/// not make the next insertion overwrite it. In that case choose the first unused gap. +pub(crate) fn next_local_id(ids: impl Iterator + Clone) -> u64 { + if let Some(next) = ids.clone().max().unwrap_or(0).checked_add(1) { + return next; + } + let mut used: Vec<_> = ids.collect(); + used.sort_unstable(); + let mut next = 1u64; + for id in used { + if id > next { + break; + } + if id == next { + // An allocated Vec cannot contain every u64 id, so this always finds a gap. + next = next.saturating_add(1); + } + } + next +} diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 452a229b4..70d56cefe 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -349,8 +349,12 @@ pub struct TextFrameOptions { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct PathType { - /// Where the text starts, as a distance along the path. + /// Where the text starts, as a distance along the path (the start bracket). pub start: f64, + /// Where the text ends, as a distance along the path (the end bracket); `None` is the path's + /// end. Text past it is overset. + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, /// Run the other way (the text sits on the other side). pub flip: bool, pub align: PathAlign, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index da0846cd6..c1c55dcfb 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -446,6 +446,16 @@ pub struct Asset { pub page: u32, } +impl Asset { + /// A raster image (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD), as opposed to a PDF, EPS or SVG. + pub fn is_raster(&self) -> bool { + matches!( + self.mime.as_str(), + "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/tiff" | "image/bmp" | "image/vnd.adobe.photoshop" + ) + } +} + fn is_zero(v: &u32) -> bool { *v == 0 } diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index 94f6b8425..46a339653 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -178,7 +178,7 @@ impl Story { } pub(crate) fn next_note_id(&self) -> u64 { - self.notes.iter().map(|n| n.id).max().unwrap_or(0).saturating_add(1) + crate::ids::next_local_id(self.notes.iter().map(|n| n.id)) } /// Insert empty footnotes for `n` new reference characters before which `k` references sit. diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index f1eaa48ad..1fa753b3b 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -246,7 +246,8 @@ impl Page { /// Split `area` into `n` equal columns separated by `gutter`. pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec { - let n = n.max(1) as usize; + // The public allocator also receives native/imported options, outside command validation. + let n = n.clamp(1, 216) as usize; let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0); (0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect() } @@ -403,3 +404,18 @@ mod tests { assert_eq!(s.page_at_x(5000.0), Some(1)); } } + +#[cfg(test)] +mod column_allocation_safety_tests { + use super::*; + + #[test] + fn column_allocation_caps_page_count_and_preserves_geometry() { + let area = Rect::new(0., 0., 300., 100.); + // Fail safely at a small boundary before exercising hostile counts. + assert_eq!(column_rects(area, 217, 0.).len(), 216); + assert_eq!(column_rects(area, u32::MAX, 0.).len(), 216); + assert_eq!(column_rects(area, 0, 12.), vec![area]); + assert_eq!(column_rects(area, 2, 12.), vec![Rect::new(0., 0., 144., 100.), Rect::new(156., 0., 300., 100.)]); + } +} diff --git a/crates/doc/src/slice.rs b/crates/doc/src/slice.rs index 6afbad109..ebf59b6a8 100644 --- a/crates/doc/src/slice.rs +++ b/crates/doc/src/slice.rs @@ -141,15 +141,20 @@ impl Story { return; } if !other.tables.is_empty() { - let mut next = self.tables.keys().max().copied().unwrap_or(0).saturating_add(1); let mut ids = std::collections::HashMap::new(); + let mut next = crate::ids::next_local_id(self.tables.keys().copied()); for (old, t) in std::mem::take(&mut other.tables) { let new = next; - next = next.saturating_add(1); let mut t = Arc::unwrap_or_clone(t); t.id = new; ids.insert(old, new); self.tables.insert(new, Arc::new(t)); + // Continue through free ids without rescanning the table map for its maximum. + // On exhaustion wrap to gaps; an allocated map cannot occupy every u64 id. + next = next.checked_add(1).unwrap_or(1); + while self.tables.contains_key(&next) { + next = next.checked_add(1).unwrap_or(1); + } } for p in &mut other.paras { p.table = p.table.and_then(|id| ids.get(&id).copied()); @@ -168,11 +173,11 @@ impl Story { self.notes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() })); } for n in other.endnotes { - let id = self.endnotes.iter().map(|x| x.id).max().unwrap_or(0).saturating_add(1); + let id = crate::ids::next_local_id(self.endnotes.iter().map(|x| x.id)); self.endnotes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() })); } for n in other.editorial { - let id = self.editorial.iter().map(|x| x.id).max().unwrap_or(0).saturating_add(1); + let id = crate::ids::next_local_id(self.editorial.iter().map(|x| x.id)); self.editorial.push(Arc::new(crate::endnotes::EditorialNote { id, ..(*n).clone() })); } self.anchors.extend(other.anchors); @@ -215,6 +220,36 @@ mod tests { assert!(a.extract(2..8).notes.is_empty()); } + #[test] + fn append_tables_preserves_sparse_and_wrapping_ids() { + for existing in [vec![1, 3, u64::MAX], vec![1, 3, u64::MAX - 1], vec![1, 3, 8]] { + let story = |id, ids: &[u64]| { + let mut s = Story::with_text(StoryId(id), &vec![crate::TABLE_ANCHOR.to_string(); ids.len()].join("\n"), ParaFormat::default()); + for (p, &tid) in s.paras.iter_mut().zip(ids) { + p.table = Some(tid); + let mut t = crate::table::Table::new(tid, 1, 1, 0, 0, 100.0); + t.cells[0].text = Story::with_text(StoryId(0), &format!("{id}:{tid}"), ParaFormat::default()); + s.tables.insert(tid, Arc::new(t)); + } + s + }; + let mut a = story(1, &existing); + let b = story(2, &[1, 2, 3]); + a.check().unwrap(); + b.check().unwrap(); + a.append_story(b); + a.check().unwrap(); + assert_eq!(a.tables.len(), 6); + for (p, tid) in a.paras.iter().take(3).zip(&existing) { + assert_eq!(p.table, Some(*tid)); + assert_eq!(a.tables[tid].cells[0].text.text, format!("1:{tid}")); + } + for (p, tid) in a.paras.iter().skip(3).zip(1..=3) { + assert_eq!(a.tables[&p.table.unwrap()].cells[0].text.text, format!("2:{tid}")); + } + } + } + #[test] fn append_story_with_maximal_ids_does_not_overflow() { let mut a = Story::with_text(StoryId(1), "Alpha", ParaFormat::default()); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 7fe385e98..9449be93c 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -695,6 +695,79 @@ impl Table { self.set_regions(®ions); } + /// Split each original owner touched by `range` once. Remember merged-cell cuts in the + /// original grid: a neighboring split may refine that grid before we reach the owner. + pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> { + if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() { + return Err("cell range out of bounds".into()); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1 { + for c in range.c0..=range.c1 { + let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?; + selected.insert(*owner); + } + } + let mut targets = Vec::new(); + for (r, c) in selected { + let cell = self.cell(r, c).ok_or("missing split cell")?; + let span = if horizontal { cell.row_span } else { cell.col_span } as usize; + let start = if horizontal { r } else { c }; + let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None }; + targets.push((r, c, cut)); + } + for i in 0..targets.len() { + let (r, c, cut) = *targets.get(i).ok_or("missing split target")?; + let cell = self.cell(r, c).ok_or("missing split cell")?; + let (rs, cs) = (cell.row_span as usize, cell.col_span as usize); + let mut blank = cell.blank_like(); + // Splitting creates an empty half, not a duplicate of graphic-cell content. + blank.graphic = None; + let before = if horizontal { self.nrows() } else { self.ncols() }; + if let Some(cut) = cut { + let (start, span) = if horizontal { (r, rs) } else { (c, cs) }; + let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?; + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect(); + if horizontal { + regions.push((r, c, first, cs)); + regions.push((cut, c, rs - first, cs)); + } else { + regions.push((r, c, rs, first)); + regions.push((r, cut, rs, cs - first)); + } + self.set_regions(®ions); + } else { + // An originally single-track cell may now span two tracks inserted for its + // neighbor. split_cell divides that span instead of inserting another track. + self.split_cell(r, c, horizontal); + } + let first = self.cell(r, c).ok_or("missing split half")?; + let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) }; + let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?; + blank.row_span = second.row_span; + blank.col_span = second.col_span; + *second = blank; + let after = if horizontal { self.nrows() } else { self.ncols() }; + if after > before { + let at = if horizontal { r + 1 } else { c + 1 }; + let added = after - before; + for (row, col, cut) in targets.iter_mut().skip(i + 1) { + let axis = if horizontal { row } else { col }; + if *axis >= at { + *axis += added; + } + if let Some(cut) = cut + && *cut >= at + { + *cut += added; + } + } + } + } + Ok(()) + } + /// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell /// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a /// new row (column) that the cells beside it span, the row height (column width) shared. @@ -719,10 +792,11 @@ impl Table { return; } if horizontal { - let h = self.rows[or].height; + let row = self.rows[or].clone(); self.insert_rows(or + 1, 1); - self.rows[or].height = h / 2.0; - self.rows[or + 1].height = h / 2.0; + self.rows[or].height = row.height / 2.0; + // Both halves keep the split row's height mode and header/body/footer role. + self.rows[or + 1] = Row { height: row.height / 2.0, ..row }; // insert_rows doesn't grow regions that end at the split row; everything else on it // spans the new row now. let mut regions: Vec<_> = self.regions(); @@ -1255,4 +1329,89 @@ mod split_tests { assert_eq!(m.nrows(), 4); assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2)); } + + #[test] + fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() { + for horizontal in [true, false] { + let mut t = Table::new(1, 4, 4, 0, 0, 280.0); + for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() { + t.rows[i].mode = RowHeightMode::Exactly; + t.rows[i].height = if horizontal { size } else { 70.0 }; + t.columns[i].width = if horizontal { 70.0 } else { size }; + } + for r in 0..4 { + for c in 0..4 { + let cell = t.cell_mut(r, c).unwrap(); + cell.text.insert(0, &format!("{r},{c}")); + cell.style = format!("style-{r}-{c}"); + cell.insets = [r as f64 + c as f64 + 1.0; 4]; + } + } + let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal { + (CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3)) + } else { + (CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6)) + }; + t.merge(merged).unwrap(); + let original = t.clone(); + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) }); + let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() }; + assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]); + let first = t.cell(owner.0, owner.1).unwrap(); + let second = t.cell(lower.0, lower.1).unwrap(); + // The original three-track merge divides after its second original track, + // regardless of which of those tracks a neighboring selected cell subdivides. + assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4); + assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2); + assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text); + assert!(second.text.is_empty()); + assert_eq!(second.style, first.style); + assert_eq!(second.insets, first.insets); + assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1)); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 18, "four original selected owners split once each"); + let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + before_text.sort(); + after_text.sort(); + assert_eq!(after_text, before_text, "no original text is copied or lost"); + } + } + + #[test] + fn split_range_resolves_covered_positions_without_expanding_selection() { + for horizontal in [true, false] { + let mut t = Table::new(1, 3, 3, 0, 0, 180.0); + t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) }; + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 8, "only the two touched owners split"); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) }); + assert_eq!(t.owner(0, 0), (0, 0)); + assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) }); + } + } + + #[test] + fn split_header_row_keeps_its_height_mode_and_role() { + let mut t = Table::new(1, 1, 2, 1, 1, 180.0); + t.rows[0].height = 60.0; + t.rows[0].mode = RowHeightMode::Exactly; + let body = t.rows[1].clone(); + let footer = t.rows[2].clone(); + t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap(); + t.check().unwrap(); + assert_eq!(t.header_rows(), 2); + for row in &t.rows[..2] { + assert_eq!(row.height, 30.0); + assert_eq!(row.mode, RowHeightMode::Exactly); + assert_eq!(row.kind, RowKind::Header); + } + assert_eq!(t.rows[2], body); + assert_eq!(t.rows[3], footer); + } } diff --git a/crates/engine/src/atomic.rs b/crates/engine/src/atomic.rs new file mode 100644 index 000000000..7eed2f028 --- /dev/null +++ b/crates/engine/src/atomic.rs @@ -0,0 +1,359 @@ +//! Crash-safe file replacement: how documents, exports and other user files reach the disk. +//! +//! [`write_atomic`] never writes the destination in place. It writes a uniquely named temporary +//! file in the same folder, flushes it (`sync_all`), renames it over the destination and then +//! flushes the folder (Unix) so the rename survives a power loss. When any step before the rename +//! fails, the temporary file is removed and the destination keeps its old bytes. +//! +//! - A replaced file keeps its permissions (Unix mode). +//! - A symbolic link keeps pointing at its file: the file it names is replaced. +//! - Windows: antivirus scanners and the search indexer briefly hold files open, which makes the +//! replacing rename fail with `PermissionDenied`; the rename is retried with a short backoff. +//! - A failed rename (cross-device, unwritable folder) is returned as an error; there is no +//! fallback to writing in place. +//! - Flushing the parent folder after replacement is best effort. A successful save confirms +//! that the new file is in place; it cannot guarantee survival of a subsequent power loss if +//! the file system rejects that folder flush. +//! +//! The web has no file system, so this module is not built for `wasm32`. + +use std::fs::File; +#[cfg(unix)] +use std::fs::OpenOptions; +use std::io::{self, Write as _}; +use std::path::{Path, PathBuf}; + +/// How many times a replacing rename is tried before its error is returned. +const RENAME_TRIES: u32 = if cfg!(windows) { 5 } else { 1 }; +/// The pause before the first rename retry; it doubles up to [`RENAME_MAX_DELAY_MS`]. +const RENAME_FIRST_DELAY_MS: u64 = 10; +const RENAME_MAX_DELAY_MS: u64 = 200; +/// Symbolic links followed before giving up (a loop, or an absurd chain). +const MAX_LINKS: usize = 40; + +/// Atomically replace (or create) `path` with `bytes`. See the module docs. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + write_atomic_with(path, |f| f.write_all(bytes)) +} + +/// [`write_atomic`] for a command's `path` parameter: the error names the path. +pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> { + write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}"))) +} + +/// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When +/// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and +/// the error is returned. +pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + let target = resolve_links(path)?; + target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?; + let dir = match target.parent() { + Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), + _ => PathBuf::from("."), + }; + let existing = std::fs::metadata(&target).ok(); + if existing.is_some() { + check_writable(&target)?; + } + let mut tmp = create_temp(&dir, existing.as_ref())?; + if let Some(existing) = existing { + // Restore the exact mode before any document bytes are written, and flush it along with + // the content. The caller's umask may have restricted the initial temporary-file mode. + keep_permissions(tmp.as_file(), &existing)?; + } + fill(tmp.as_file_mut())?; + tmp.as_file().sync_all()?; + // Closing the file before renaming also allows replacement on Windows. TempPath removes the + // staging file on every error; NamedTempFile does the same if the writer unwinds. + let tmp = tmp.into_temp_path(); + retry_rename(RENAME_TRIES, || std::fs::rename(&tmp, &target))?; + // The new bytes are in place. A failed folder flush can't be undone and must not report the + // save as failed. + let _ = sync_dir(&dir); + Ok(()) +} + +/// Create a fresh temporary file beside the target (the same folder, so the rename stays on one +/// volume). A short randomized name supports long destination names and skips existing entries. +fn create_temp(dir: &Path, existing: Option<&std::fs::Metadata>) -> io::Result { + let mut builder = tempfile::Builder::new(); + builder.prefix(".designcraft-save-").suffix(".tmp"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + // Private replacements are private from creation. New files retain fs::write's normal + // 0666 mode restricted by the caller's umask. + let mode = existing.map_or(0o666, |metadata| metadata.permissions().mode()); + builder.permissions(std::fs::Permissions::from_mode(mode)); + } + #[cfg(not(unix))] + let _ = existing; + builder.tempfile_in(dir) +} + +/// The file a chain of symbolic links ends at (`path` itself when it is not a link). A dangling +/// link resolves to the missing file it names, which is then created. +fn resolve_links(path: &Path) -> io::Result { + let mut p = path.to_path_buf(); + for _ in 0..MAX_LINKS { + match std::fs::symlink_metadata(&p) { + Ok(m) if m.file_type().is_symlink() => { + let to = std::fs::read_link(&p)?; + p = match p.parent() { + Some(dir) if to.is_relative() => dir.join(to), + _ => to, + }; + } + _ => return Ok(p), + } + } + Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display()))) +} + +/// A rename replaces a file the user can't write (Unix only needs the folder to be writable); +/// refuse, as writing it in place would. Opening for writing without truncating changes nothing. +#[cfg(unix)] +fn check_writable(target: &Path) -> io::Result<()> { + OpenOptions::new().write(true).open(target).map(drop) +} + +/// Windows refuses to rename over a read-only file by itself. +#[cfg(not(unix))] +fn check_writable(_: &Path) -> io::Result<()> { + Ok(()) +} + +/// The replacement keeps the replaced file's mode (Unix; Windows files keep only a read-only flag, +/// and a read-only file can't be replaced). +#[cfg(unix)] +fn keep_permissions(tmp: &File, replaced: &std::fs::Metadata) -> io::Result<()> { + tmp.set_permissions(replaced.permissions()) +} + +#[cfg(not(unix))] +fn keep_permissions(_: &File, _: &std::fs::Metadata) -> io::Result<()> { + Ok(()) +} + +/// Run `rename` up to `tries` times, pausing with a doubling delay while it fails with +/// `PermissionDenied` (a scanner holding the file on Windows). Other errors return at once. +fn retry_rename(tries: u32, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> { + let mut delay = RENAME_FIRST_DELAY_MS; + let mut left = tries.max(1); + loop { + left = left.saturating_sub(1); + match rename() { + Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => { + std::thread::sleep(std::time::Duration::from_millis(delay)); + delay = delay.saturating_mul(2).min(RENAME_MAX_DELAY_MS); + } + r => return r, + } + } +} + +/// Flush a folder's entries (Unix). Rust's portable file API cannot open a directory on Windows; +/// this does not establish power-loss durability for its directory entries. +#[cfg(unix)] +fn sync_dir(dir: &Path) -> io::Result<()> { + File::open(dir)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_dir(_: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("dc-atomic-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// The names in `d`, sorted. + fn names(d: &Path) -> Vec { + let mut v: Vec = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + v.sort(); + v + } + + #[test] + fn replaces_and_creates_without_leftovers() { + let d = dir("replace"); + let path = d.join("doc.designcraft"); + write_atomic(&path, b"first").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"first"); + write_atomic(&path, b"second, longer").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"second, longer"); + write_atomic(&path, b"3").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"3"); + assert_eq!(names(&d), ["doc.designcraft"], "no temporary file is left"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_failed_write_leaves_the_original_and_no_temporary_file() { + let d = dir("fail"); + let path = d.join("doc.designcraft"); + std::fs::write(&path, b"original").unwrap(); + let r = write_atomic_with(&path, |f| { + f.write_all(b"half a docu")?; + Err(io::Error::other("disk full")) + }); + assert_eq!(r.unwrap_err().to_string(), "disk full"); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + assert_eq!(names(&d), ["doc.designcraft"], "the temporary file is removed"); + // A folder that doesn't exist: an error, nothing written. + assert!(write_atomic(&d.join("missing").join("x.designcraft"), b"x").is_err()); + assert_eq!(names(&d), ["doc.designcraft"]); + // A folder in the way: an error, the folder stays and nothing is left beside it. + std::fs::create_dir(d.join("folder")).unwrap(); + assert!(write_atomic(&d.join("folder"), b"x").is_err()); + assert_eq!(names(&d), ["doc.designcraft", "folder"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_long_destination_name_does_not_lengthen_the_temporary_name() { + let d = dir("long-name"); + let name = format!("{}.designcraft", "a".repeat(235)); + let path = d.join(&name); + std::fs::write(&path, b"old").unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(names(&d), [name]); + let _ = std::fs::remove_dir_all(&d); + } + + #[cfg(unix)] + #[test] + fn private_permissions_are_preserved_while_the_file_is_written() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("private-staging"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_atomic_with(&path, |file| { + assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600); + file.write_all(b"private document")?; + assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600); + Ok(()) + }) + .unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"private document"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(names(&d), ["private.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn an_unwinding_writer_removes_its_partial_temporary_file() { + let d = dir("unwinding-writer"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + let result = std::panic::catch_unwind(|| { + write_atomic_with(&path, |file| { + file.write_all(b"partial private document")?; + panic!("synthetic writer panic"); + }) + }); + assert!(result.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!(names(&d), ["private.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn the_rename_is_retried_only_while_access_is_denied() { + let mut calls = 0; + let r = retry_rename(3, || { + calls += 1; + if calls < 3 { Err(io::Error::from(io::ErrorKind::PermissionDenied)) } else { Ok(()) } + }); + assert!(r.is_ok() && calls == 3); + calls = 0; + let r = retry_rename(3, || { + calls += 1; + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + }); + assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::PermissionDenied)); + assert_eq!(calls, 3, "gives up after the last try"); + calls = 0; + let r = retry_rename(3, || { + calls += 1; + Err(io::Error::from(io::ErrorKind::CrossesDevices)) + }); + assert!(r.is_err() && calls == 1, "other errors are not retried"); + } + + #[cfg(unix)] + #[test] + fn permissions_are_kept_and_unwritable_files_are_not_replaced() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("perms"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + // A read-only file stays as it is (root may write it anyway). + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap(); + if OpenOptions::new().write(true).open(&path).is_err() { + assert!(write_atomic(&path, b"newer").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(names(&d), ["private.designcraft"]); + } + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let _ = std::fs::remove_dir_all(&d); + } + + /// A folder the user can't write: the save fails and the original stays. + #[cfg(unix)] + #[test] + fn an_unwritable_folder_fails_without_touching_the_file() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("ro-dir"); + let path = d.join("doc.designcraft"); + std::fs::write(&path, b"original").unwrap(); + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o555)).unwrap(); + // Root writes anyway; the check only means something for an ordinary user. + let probe = d.join("probe"); + let unwritable = std::fs::write(&probe, b"").is_err(); + if unwritable { + assert!(write_atomic(&path, b"new").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + } + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap(); + let _ = std::fs::remove_file(&probe); + assert_eq!(names(&d), ["doc.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[cfg(unix)] + #[test] + fn a_symbolic_link_keeps_pointing_at_the_replaced_file() { + let d = dir("link"); + std::fs::create_dir(d.join("real")).unwrap(); + let target = d.join("real").join("doc.designcraft"); + std::fs::write(&target, b"old").unwrap(); + let link = d.join("link.designcraft"); + std::os::unix::fs::symlink("real/doc.designcraft", &link).unwrap(); + write_atomic(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert_eq!(names(&d), ["link.designcraft", "real"]); + assert_eq!(names(&d.join("real")), ["doc.designcraft"]); + // A loop of links is an error, not a hang. + std::os::unix::fs::symlink("b", d.join("a")).unwrap(); + std::os::unix::fs::symlink("a", d.join("b")).unwrap(); + assert!(write_atomic(&d.join("a"), b"x").is_err()); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 9631dac2b..fbf80ed9e 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -28,7 +28,7 @@ fn no_book() -> EngineError { #[cfg(not(target_arch = "wasm32"))] fn save(b: &Book) -> Result<()> { let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?; - std::fs::write(&b.path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", b.path))) + crate::atomic::write_file(&b.path, &bytes) } #[cfg(target_arch = "wasm32")] @@ -47,20 +47,176 @@ fn load(path: &str) -> Result { if path.to_lowercase().ends_with(".idml") { return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); } - super::file::from_bytes(&bytes) + super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } -fn store(path: &str, d: &designcraft_doc::Document) -> Result<()> { - #[cfg(not(target_arch = "wasm32"))] - return std::fs::write(path, super::file::to_bytes(d)).map_err(|e| EngineError::Other(format!("{path}: {e}"))); - #[cfg(target_arch = "wasm32")] - { - let _ = (path, d); - Err(EngineError::Other("books need a file system".into())) +#[cfg(not(target_arch = "wasm32"))] +const MAX_BATCH_CHAPTERS: usize = 4096; + +/// A batch keeps only bounded path records in memory. Chapter snapshots live in a private +/// temporary directory; only one document and serialized blob are live while staging. +#[cfg(not(target_arch = "wasm32"))] +struct ChapterBatch { + directory: tempfile::TempDir, + chapters: Vec<(String, tempfile::TempPath)>, + command: &'static str, +} + +#[cfg(not(target_arch = "wasm32"))] +impl ChapterBatch { + fn new(chapters: usize, command: &'static str) -> Result { + if chapters > MAX_BATCH_CHAPTERS { + return Err(bad(command, format!("a book update supports at most {MAX_BATCH_CHAPTERS} chapters"))); + } + let directory = tempfile::Builder::new() + .prefix(".designcraft-book-") + .tempdir() + .map_err(|e| EngineError::Other(format!("{command}: cannot stage chapter changes: {e}")))?; + Ok(Self { directory, chapters: Vec::with_capacity(chapters), command }) + } + + fn stage(&mut self, path: &str, doc: &designcraft_doc::Document) -> Result<()> { + use std::io::Write; + + // Propagate serialization errors; an empty fallback must never replace a chapter. + let bytes = designcraft_format::save(doc).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut file = tempfile::Builder::new() + .prefix("chapter-") + .tempfile_in(self.directory.path()) + .map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?; + file.write_all(&bytes).map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?; + self.chapters.push((path.to_string(), file.into_temp_path())); + Ok(()) + } + + fn commit(self) -> Result<()> { + self.commit_with(write_staged_chapter) + } + + fn commit_with(self, mut write: impl FnMut(&std::path::Path, &std::path::Path) -> std::io::Result<()>) -> Result<()> { + let mut completed = Vec::with_capacity(self.chapters.len()); + for (path, staged) in self.chapters { + if let Err(e) = write(std::path::Path::new(&path), &staged) { + // Each file is atomic; the whole book is not. Keep the successful prefix visible + // rather than pretending that an error undid earlier chapter replacements. + return Err(EngineError::Other(format!( + "{}: failed to save chapter {path}: {e}; completed chapter paths: {completed:?}", + self.command + ))); + } + completed.push(path); + } + Ok(()) } } +#[cfg(not(target_arch = "wasm32"))] +fn write_staged_chapter(path: &std::path::Path, staged: &std::path::Path) -> std::io::Result<()> { + let mut input = std::fs::File::open(staged)?; + crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ())) +} + +/// Batch commands only write closed native chapters. IDML is an imported source, and an open +/// chapter's model (including unsaved edits) cannot be reconciled by a disk-only book update. +#[cfg(not(target_arch = "wasm32"))] +fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> { + use std::path::Path; + + if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) { + return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first"))); + } + let target = std::fs::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| std::fs::canonicalize(open).is_ok_and(|open| open == target))) { + return Err(bad(command, format!("chapter {path} is open; close it before updating the book"))); + } + if !std::fs::metadata(&target).is_ok_and(|metadata| metadata.is_file()) { + return Err(bad(command, format!("chapter {path} is not a regular file"))); + } + // Check both the file and its resolved parent without truncating it. Replacement needs a + // writable directory even when an in-place write would be permitted. + std::fs::OpenOptions::new().write(true).open(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let parent = target.parent().ok_or_else(|| bad(command, format!("chapter {path} has no parent folder")))?; + tempfile::Builder::new().prefix(".designcraft-book-check-").tempfile_in(parent).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn paginate(s: &Session) -> Result { + let b = s.book.as_ref().ok_or_else(no_book)?; + if b.documents.is_empty() { + return Ok(json!({"pages": 0})); + } + let mut batch = ChapterBatch::new(b.documents.len(), "book.paginate")?; + let mut next = 1u32; + for path in &b.documents { + writable_chapter(s, path, "book.paginate")?; + let mut d = load(path)?; + start_at(&mut d, next); + let pages = u32::try_from(d.page_count()).map_err(|_| bad("book.paginate", "too many pages for continuous numbering"))?; + next = next.checked_add(pages).ok_or_else(|| bad("book.paginate", "too many pages for continuous numbering"))?; + batch.stage(path, &d)?; + } + batch.commit()?; + Ok(json!({"pages": next - 1})) +} + +#[cfg(not(target_arch = "wasm32"))] +fn sync_styles(s: &Session) -> Result { + let b = s.book.as_ref().ok_or_else(no_book)?; + let mut batch = ChapterBatch::new(b.documents.len(), "book.syncStyles")?; + let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?; + let src = load(src_path)?; + let styles = src.styles.clone(); + let swatches = src.swatches.clone(); + drop(src); + let mut n = 0; + for (i, path) in b.documents.iter().enumerate() { + if i == b.style_source { + continue; + } + writable_chapter(s, path, "book.syncStyles")?; + let mut d = load(path)?; + { + let st = d.styles_mut(); + for ps in &styles.paragraph { + st.paragraph.retain(|x| x.name != ps.name); + st.paragraph.push(ps.clone()); + } + for cs in &styles.character { + st.character.retain(|x| x.name != cs.name); + st.character.push(cs.clone()); + } + } + for sw in &swatches { + match d.swatches.iter_mut().find(|w| w.name == sw.name) { + Some(w) if !w.locked => *w = sw.clone(), + Some(_) => {} + None => d.swatches.push(sw.clone()), + } + } + batch.stage(path, &d)?; + n += 1; + } + batch.commit()?; + Ok(json!({"synced": n})) +} + +#[cfg(target_arch = "wasm32")] +fn paginate(_: &Session) -> Result { + Err(EngineError::Other("books need a file system".into())) +} + +#[cfg(target_arch = "wasm32")] +fn sync_styles(_: &Session) -> Result { + Err(EngineError::Other("books need a file system".into())) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +#[path = "book/safety_tests.rs"] +mod safety_tests; + /// Make page numbering of `d` start at `n` (the first section). +#[cfg(not(target_arch = "wasm32"))] fn start_at(d: &mut designcraft_doc::Document, n: u32) { if !d.sections.iter().any(|x| x.start == 0) { d.sections.insert( @@ -108,15 +264,16 @@ pub fn specs() -> Vec { cmd!(noundo "book.add", "Add Document", [], None, "{path, at?: index}", has_book, |s, p| { let path = str_param(p, "path").ok_or_else(|| bad("book.add", "`path` required"))?.to_string(); load(&path)?; - let b = s.book.as_mut().ok_or_else(no_book)?; + let mut b = s.book.as_ref().ok_or_else(no_book)?.clone(); let at = p.get("at").and_then(Value::as_u64).map_or(b.documents.len(), |i| (i as usize).min(b.documents.len())); b.documents.insert(at, path); - save(b)?; + save(&b)?; + s.book = Some(b); ok() }), cmd!(noundo "book.remove", "Remove Document", [], None, "{index}", has_book, |s, p| { let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("book.remove", "`index` required"))? as usize; - let b = s.book.as_mut().ok_or_else(no_book)?; + let mut b = s.book.as_ref().ok_or_else(no_book)?.clone(); if i >= b.documents.len() { return Err(bad("book.remove", format!("no document {i}"))); } @@ -124,17 +281,19 @@ pub fn specs() -> Vec { if b.style_source >= b.documents.len() { b.style_source = 0; } - save(b)?; + save(&b)?; + s.book = Some(b); ok() }), cmd!(noundo "book.styleSource", "Style Source", [], None, "{index}", has_book, |s, p| { let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; - let b = s.book.as_mut().ok_or_else(no_book)?; + let mut b = s.book.as_ref().ok_or_else(no_book)?.clone(); if i >= b.documents.len() { return Err(bad("book.styleSource", format!("no document {i}"))); } b.style_source = i; - save(b)?; + save(&b)?; + s.book = Some(b); ok() }), cmd!(query "book.list", "Book", [], None, "{} → {path, styleSource, documents: [{path, pages, firstPage}]}", has_book, |s, _| { @@ -149,48 +308,10 @@ pub fn specs() -> Vec { Ok(json!({"path": b.path, "styleSource": b.style_source, "documents": docs})) }), cmd!(noundo "book.paginate", "Update Numbering", [], None, "{} — each document starts numbering where the previous one ended (files are saved)", has_book, |s, _| { - let b = s.book.clone().ok_or_else(no_book)?; - let mut next = 1u32; - for p in &b.documents { - let mut d = load(p)?; - start_at(&mut d, next); - next += d.page_count() as u32; - store(p, &d)?; - } - Ok(json!({"pages": next - 1})) + paginate(s) }), cmd!(noundo "book.syncStyles", "Synchronize Book", [], None, "{} — paragraph and character styles and swatches of the style source go into every document (by name)", has_book, |s, _| { - let b = s.book.clone().ok_or_else(no_book)?; - let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?; - let src = load(src_path)?; - let mut n = 0; - for (i, p) in b.documents.iter().enumerate() { - if i == b.style_source { - continue; - } - let mut d = load(p)?; - { - let st = d.styles_mut(); - for ps in &src.styles.paragraph { - st.paragraph.retain(|x| x.name != ps.name); - st.paragraph.push(ps.clone()); - } - for cs in &src.styles.character { - st.character.retain(|x| x.name != cs.name); - st.character.push(cs.clone()); - } - } - for sw in &src.swatches { - match d.swatches.iter_mut().find(|w| w.name == sw.name) { - Some(w) if !w.locked => *w = sw.clone(), - Some(_) => {} - None => d.swatches.push(sw.clone()), - } - } - store(p, &d)?; - n += 1; - } - Ok(json!({"synced": n})) + sync_styles(s) }), cmd!(noundo "book.exportPdf", "Export Book to PDF…", [], None, "{path?} — every document in order as one PDF → {path, bytes, pages} (no path: {base64, …})", has_book, |s, p| { let b = s.book.clone().ok_or_else(no_book)?; @@ -210,7 +331,7 @@ pub fn specs() -> Vec { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})), @@ -235,6 +356,7 @@ mod tests { s.execute("file.new", &json!({"pages": pages})).unwrap(); s.execute("style.paragraph.create", &json!({"name": style, "chars": {"size": 20}})).unwrap(); s.execute("file.saveAs", &json!({"path": p(name)})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); } s.execute("book.new", &json!({"path": p("book.dcbook")})).unwrap(); s.execute("book.add", &json!({"path": p("a.designcraft")})).unwrap(); diff --git a/crates/engine/src/cmd/book/safety_tests.rs b/crates/engine/src/cmd/book/safety_tests.rs new file mode 100644 index 000000000..e4ec20efd --- /dev/null +++ b/crates/engine/src/cmd/book/safety_tests.rs @@ -0,0 +1,52 @@ +use super::{ChapterBatch, MAX_BATCH_CHAPTERS, write_staged_chapter}; +use crate::Session; +use serde_json::json; + +#[test] +fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("first.designcraft"); + let second = dir.path().join("second.designcraft"); + let third = dir.path().join("third.designcraft"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let old = (*s.doc().unwrap().doc).clone(); + let mut changed = old.clone(); + changed.sections.first_mut().unwrap().start_number = Some(9); + let old_bytes = designcraft_format::save(&old).unwrap(); + for path in [&first, &second, &third] { + std::fs::write(path, &old_bytes).unwrap(); + } + let mut batch = ChapterBatch::new(3, "book.paginate").unwrap(); + for path in [&first, &second, &third] { + batch.stage(path.to_str().unwrap(), &changed).unwrap(); + } + let staging = batch.directory.path().to_path_buf(); + let mut calls = 0; + let error = batch + .commit_with(|path, staged| { + calls += 1; + if path == second { Err(std::io::Error::other("synthetic disk full")) } else { write_staged_chapter(path, staged) } + }) + .unwrap_err() + .to_string(); + assert_eq!(calls, 2); + assert!(error.contains("synthetic disk full"), "{error}"); + assert!(error.contains(second.to_str().unwrap()), "{error}"); + let completed = error.split("completed chapter paths:").nth(1).unwrap(); + assert!(completed.contains(first.to_str().unwrap()), "{error}"); + assert!(!completed.contains(second.to_str().unwrap()) && !completed.contains(third.to_str().unwrap()), "{error}"); + assert_eq!(designcraft_format::load(&std::fs::read(first).unwrap()).unwrap().sections.first().unwrap().start_number, Some(9)); + assert_eq!(std::fs::read(second).unwrap(), old_bytes); + assert_eq!(std::fs::read(third).unwrap(), old_bytes); + assert!(!staging.exists(), "temporary chapter snapshots must be removed on a partial failure"); +} + +#[test] +fn chapter_record_limit_is_checked_before_staging_allocation() { + let error = match ChapterBatch::new(MAX_BATCH_CHAPTERS + 1, "book.paginate") { + Err(e) => e, + Ok(_) => panic!("oversized book batch accepted"), + }; + assert!(error.to_string().contains("4096")); +} diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index e65493dfc..6ab10f3d9 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -367,10 +367,16 @@ fn sniff(name: &str, text: &str) -> Delimiter { if ext == "csv" { return Delimiter::Comma; } - let header = first_physical_line(text); - if header.contains('\t') { + let tab = header_delimiter_candidate(text, '\t'); + let semicolon = header_delimiter_candidate(text, ';'); + let comma = header_delimiter_candidate(text, ','); + // Prefer formats with coherent quoted-field boundaries. If none separates fields, + // retain the legacy permissive choice; this is detection, not record validation. + let prefer_coherent = [&tab, &semicolon, &comma].iter().any(|c| c.found && c.coherent); + let eligible = |c: &HeaderDelimiterCandidate| c.found && (!prefer_coherent || c.coherent); + if eligible(&tab) { Delimiter::Tab - } else if header.contains(';') && !header.contains(',') { + } else if eligible(&semicolon) && !eligible(&comma) { Delimiter::Semicolon } else { Delimiter::Comma @@ -381,8 +387,66 @@ pub fn extension(name: &str) -> String { Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() } -fn first_physical_line(text: &str) -> &str { - text.split('\n').next().unwrap_or("").trim_end_matches('\r') +struct HeaderDelimiterCandidate { + found: bool, + coherent: bool, +} + +/// A candidate separator outside quotes in its first nonempty logical record. +/// Only that candidate starts a new field. Quotes in an unquoted field stay literal; +/// Non-whitespace text after a quoted field closes lowers coherence, not validity. +fn header_delimiter_candidate(text: &str, delimiter: char) -> HeaderDelimiterCandidate { + let mut found = false; + let mut coherent = true; + let mut quoted = false; + let mut after_quote = false; + let mut field_start = true; + let mut nonempty = false; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if quoted { + if c == '"' { + if chars.peek() == Some(&'"') { + chars.next(); + nonempty = true; + } else { + quoted = false; + after_quote = true; + } + } else if !c.is_whitespace() { + nonempty = true; + } + continue; + } + match c { + '"' if field_start => { + quoted = true; + field_start = false; + } + separator if separator == delimiter => { + found = true; + field_start = true; + after_quote = false; + } + '\r' | '\n' => { + if nonempty { + break; + } + found = false; + coherent = true; + field_start = true; + after_quote = false; + } + _ => { + // Whitespace may follow a closing quote, but does not start a new field + // or allow a later non-whitespace suffix to regain coherence. + coherent &= !after_quote || c.is_whitespace(); + nonempty |= !c.is_whitespace(); + field_start = false; + } + } + } + HeaderDelimiterCandidate { found, coherent: coherent && !quoted } } /// Header cell: trim, drop one leading apostrophe, then `@` is image and `#` is qr. @@ -452,6 +516,7 @@ fn parse_delimited(s: &str, delim: char) -> Result, String> { let mut row: Vec = Vec::new(); let mut field = String::new(); let mut quoted = false; + let mut field_start = true; let mut line = 1usize; let mut row_line = 1usize; let mut data_rows = 0usize; @@ -501,21 +566,25 @@ fn parse_delimited(s: &str, delim: char) -> Result, String> { }; while let Some(c) = chars.next() { - if c == '\n' { + if c == '\n' || (c == '\r' && chars.peek() != Some(&'\n')) { line = line.saturating_add(1); } if quoted { match c { '"' if chars.peek() == Some(&'"') => { - field.push('"'); + if !discarding { + field.push('"'); + } chars.next(); } '"' => quoted = false, - _ => field.push(c), + _ if !discarding => field.push(c), + _ => {} } continue; } if c == delim { + field_start = true; if discarding { field.clear(); continue; @@ -531,14 +600,25 @@ fn parse_delimited(s: &str, delim: char) -> Result, String> { continue; } match c { - '"' if field.is_empty() && !discarding => quoted = true, - '\r' => {} - '\n' => { + '"' if field_start => { + quoted = true; + field_start = false; + } + '\r' | '\n' => { + if c == '\r' && chars.peek() == Some(&'\n') { + chars.next(); + line = line.saturating_add(1); + } finish(&mut row, &mut field, &mut rows, &mut data_rows, &mut header_width, row_line, &mut discarding)?; row_line = line; + field_start = true; + } + _ => { + field_start = false; + if !discarding { + field.push(c); + } } - _ if discarding => {} - _ => field.push(c), } } if !field.is_empty() || !row.is_empty() || discarding { diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index e4636d109..f4c2004e4 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -85,6 +85,282 @@ fn csv_parsing() { assert_eq!(quoted[1][0], "Ada\nGrace"); } +#[test] +fn data_source_sniffs_quoted_logical_headers() { + for (body, delimiter, names) in [ + ("\"Last, First\";City\nAda;London\n", Delimiter::Semicolon, ["Last, First", "City"]), + ("\"Name\tLabel\",City\nAda,London\n", Delimiter::Comma, ["Name\tLabel", "City"]), + ("\"Name\nLabel\";City\nAda;London\n", Delimiter::Semicolon, ["Name\nLabel", "City"]), + ("\r\n \r\nName;City\r\nAda;London\r\n", Delimiter::Semicolon, ["Name", "City"]), + ("\"Name \"\"label,\"\"\";City\nAda;London\n", Delimiter::Semicolon, ["Name \"label,\"", "City"]), + ] { + let mut s = Session::new(); + new_doc(&mut s, 1); + let result = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + assert_eq!(result["records"], 1, "{body:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.delimiter, delimiter, "{body:?}"); + assert_eq!(source.fields.iter().map(|f| f.name.as_str()).collect::>(), names, "{body:?}"); + assert_eq!(source.rows, vec![vec!["Ada", "London"]], "{body:?}"); + } +} + +#[test] +fn data_source_sniff_quote_state_belongs_to_the_candidate_delimiter() { + for ending in ["\n", "\r\n", "\r"] { + for (body, delimiter, names) in [ + (format!("Name,\"preferred\tCity\"{ending}Ada\tLondon{ending}"), Delimiter::Tab, ["Name,\"preferred", "City\""]), + (format!("\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]), + (format!("\"Name\tLabel\",City{ending}Ada,London{ending}"), Delimiter::Comma, ["Name\tLabel", "City"]), + (format!("\"\";\"\"{ending} \t{ending}\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]), + ] { + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}"); + assert!(automatic.warnings.is_empty(), "{body:?}: {:?}", automatic.warnings); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_prefers_coherent_quoted_field_boundaries() { + for ending in ["\n", "\r\n", "\r"] { + for (header, first, second, delimiter, names, rows, warnings) in [ + ( + "\"Name\";\"Last, First\"", + "\"Ada\";\"Lovelace, Countess\"", + "\"Grace\";\"Hopper, Admiral\"", + Delimiter::Semicolon, + vec!["Name", "Last, First"], + vec![vec!["Ada", "Lovelace, Countess"], vec!["Grace", "Hopper, Admiral"]], + vec![], + ), + ( + "\"Name\",\"Region\tCity\"", + "\"Ada\",\"London\"", + "\"Grace\",\"Paris\"", + Delimiter::Comma, + vec!["Name", "Region\tCity"], + vec![vec!["Ada", "London"], vec!["Grace", "Paris"]], + vec![], + ), + ( + "\"Name\";\"City\";\"Last, \"\"First\"\"\";\"Title\"", + "\"Ada\";\"London\";\"Lovelace, \"\"Countess\"\"\";\"Analyst\"", + "\"Grace\";\"Paris\";\"Hopper, \"\"Admiral\"\"\";\"Engineer\";\"ignored\"", + Delimiter::Semicolon, + vec!["Name", "City", "Last, \"First\"", "Title"], + vec![vec!["Ada", "London", "Lovelace, \"Countess\"", "Analyst"], vec!["Grace", "Paris", "Hopper, \"Admiral\"", "Engineer"]], + vec!["Row 3 has cells past the header. Those cells were ignored."], + ), + ( + "\"Name\",\"Role\",\"Region\t\"\"City\"\"\",\"Title\"", + "\"Ada\",\"Analyst\",\"London\t\"\"West End\"\"\",\"Countess\"", + "\"Grace\",\"Engineer\",\"Paris\t\"\"Centre\"\"\",\"Admiral\",\"ignored\"", + Delimiter::Comma, + vec!["Name", "Role", "Region\t\"City\"", "Title"], + vec![vec!["Ada", "Analyst", "London\t\"West End\"", "Countess"], vec!["Grace", "Engineer", "Paris\t\"Centre\"", "Admiral"]], + vec!["Row 3 has cells past the header. Those cells were ignored."], + ), + ] { + let body = format!("{header}{ending}{first}{ending}{second}{ending}"); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, rows, "{body:?}"); + assert_eq!(automatic.warnings, warnings, "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_retains_priority_and_permissive_fallback() { + for ending in ["\n", "\r\n", "\r"] { + for (header, row, delimiter, names, cells) in [ + // Equally coherent candidates retain Tab, then Comma, then Semicolon priority. + ("Name,Title\tCity", "Ada,Analyst\tLondon\tignored", Delimiter::Tab, ["Name,Title", "City"], ["Ada,Analyst", "London"]), + ("Name;Title,City", "Ada;Analyst,London,ignored", Delimiter::Comma, ["Name;Title", "City"], ["Ada;Analyst", "London"]), + // No coherent candidate separates fields here. Keep parsing the trailing text + // after a quoted field exactly as before, using the same delimiter priority. + ( + "\"Name\"suffix,Title\tCity", + "\"Ada\"suffix,Analyst\tLondon\tignored", + Delimiter::Tab, + ["Namesuffix,Title", "City"], + ["Adasuffix,Analyst", "London"], + ), + ( + "\"Name\"suffix;Title,City", + "\"Ada\"suffix;Analyst,London,ignored", + Delimiter::Comma, + ["Namesuffix;Title", "City"], + ["Adasuffix;Analyst", "London"], + ), + ("\"Name\"suffix;City", "\"Ada\"suffix;London;ignored", Delimiter::Semicolon, ["Namesuffix", "City"], ["Adasuffix", "London"]), + ] { + let body = format!("{header}{ending}{row}{ending}"); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, vec![cells.to_vec()], "{body:?}"); + assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_allows_post_quote_whitespace() { + for ending in ["\n", "\r\n", "\r"] { + for space in [" ", "\u{00A0}", "\u{2003}"] { + for (header, separator, delimiter, names) in [ + (format!("\"Name\"{space};\"Last, First\""), ';', Delimiter::Semicolon, ["Name", "Last, First"]), + (format!("\"Name\"{space},\"Region\tCity\"{space}"), ',', Delimiter::Comma, ["Name", "Region\tCity"]), + (format!("\"Name\"{space}\t\"Last, First; City\"{space}"), '\t', Delimiter::Tab, ["Name", "Last, First; City"]), + ] { + let body = format!( + "{header}{ending}\"Ada\"{space}{separator}\"London\"{space}{ending}\"Grace\"{space}{separator}\"Paris\"{space}{separator}ignored{ending}" + ); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + // Header classification trims; record values must keep their whitespace. + assert_eq!( + automatic.rows, + vec![vec![format!("Ada{space}"), format!("London{space}")], vec![format!("Grace{space}"), format!("Paris{space}")]], + "{body:?}" + ); + assert_eq!(automatic.warnings, vec!["Row 3 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } + } +} + +#[test] +fn data_source_sniff_post_quote_whitespace_keeps_closed_field_state() { + for ending in ["\n", "\r\n", "\r"] { + for space in [" ", "\u{00A0}", "\u{2003}"] { + for suffix in ["suffix", "\"suffix\""] { + let body = format!("\"Name\"{space}{suffix};\"Last, First\"{ending}Ada,London,ignored{ending}"); + // Both separated formats have incompatible post-quote suffixes. Keep + // the permissive Comma fallback, including literal quotes in the name. + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(Delimiter::Comma)).unwrap(); + assert_eq!(automatic.delimiter, Delimiter::Comma, "{body:?}"); + assert_eq!( + automatic.fields, + vec![ + DataField { name: format!("Name{space}{suffix};\"Last"), kind: DataFieldKind::Text }, + DataField { name: "First\"".into(), kind: DataFieldKind::Text }, + ], + "{body:?}" + ); + assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}"); + assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } + } +} + +#[test] +fn data_source_literal_quote_header_merges_the_detected_column() { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = "Name,\"preferred\tCity\"\nAda\tLondon\nGrace\tParis\n"; + let selected = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + assert_eq!(selected["records"], 2); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.delimiter, Delimiter::Tab); + assert_eq!(source.rows, vec![vec!["Ada", "London"], vec!["Grace", "Paris"]]); + exec(&mut s, "data.placeholder.add", json!({"field": "City\""})); + let merged = exec(&mut s, "data.merge", json!({})); + assert_eq!(merged["records"], 2); + assert_eq!(merged["pages"], 2); + assert_eq!(texts(&s), vec!["London", "Paris"]); +} + +#[test] +fn data_source_preserves_quoted_newlines_and_counts_record_lines() { + for ending in ["\n", "\r\n", "\r"] { + let body = format!("Name,City{ending}\"Ada{ending}Lovelace\",London{ending}Grace,Paris,extra{ending}"); + let mut s = Session::new(); + new_doc(&mut s, 1); + let result = select_csv(&mut s, &body); + assert_eq!(result["records"], 2, "{ending:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.rows, vec![vec![format!("Ada{ending}Lovelace"), "London".into()], vec!["Grace".into(), "Paris".into()]]); + assert_eq!(source.warnings.len(), 1); + assert!(source.warnings[0].contains("Row 4"), "{:?}", source.warnings); + } +} + +#[test] +fn data_source_quoted_header_and_cr_records_merge_independently() { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = "\u{FEFF}\"Last, First\";City\rAda;London\rGrace;Paris\r"; + exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Last, First"})); + let template = s.documents()[0].doc.clone(); + let result = exec(&mut s, "data.merge", json!({})); + assert_eq!(result["records"], 2); + assert_eq!(result["pages"], 2); + assert_eq!(texts(&s), vec!["Ada", "Grace"]); + assert_eq!(serde_json::to_value(&s.documents()[0].doc).unwrap(), serde_json::to_value(&template).unwrap()); +} + +#[test] +fn data_source_discarded_quoted_fields_keep_records_and_warning_lines() { + for ending in ["\n", "\r\n", "\r"] { + for (discarded, following_line) in [(format!("\"Address \"\"line 1\"\"{ending}Address line 2\""), 4), ("literal\"quote".into(), 3)] { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = format!("Name{ending}Ada,,,{discarded}{ending}Grace,extra{ending}"); + let selected = select_csv(&mut s, &body); + assert_eq!(selected["records"], 2, "{body:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.rows, vec![vec!["Ada"], vec!["Grace"]], "{body:?}"); + assert_eq!(source.warnings.len(), 2, "{:?}", source.warnings); + assert!(source.warnings[0].contains("Row 2"), "{:?}", source.warnings); + assert!(source.warnings[1].contains(&format!("Row {following_line}")), "{:?}", source.warnings); + exec(&mut s, "data.placeholder.add", json!({"field": "Name"})); + let merged = exec(&mut s, "data.merge", json!({})); + assert_eq!(merged["records"], 2); + assert_eq!(merged["pages"], 2); + assert_eq!(texts(&s), vec!["Ada", "Grace"]); + } + } +} + +#[test] +fn data_source_delimiter_override_and_known_extensions_win() { + let body = "Name;City\nAda;London\n"; + let csv = parse::table_from_bytes(body.as_bytes(), "people.csv", None).unwrap(); + assert_eq!(csv.delimiter, Delimiter::Comma); + assert_eq!(csv.rows, vec![vec!["Ada;London"]]); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.csv", Some(Delimiter::Semicolon)).unwrap(); + assert_eq!(explicit.rows, vec![vec!["Ada", "London"]]); + let tab = parse::table_from_bytes(b"Name,Title\tCity\nAda,Analyst\tLondon", "people.tsv", None).unwrap(); + assert_eq!(tab.delimiter, Delimiter::Tab); + assert_eq!(tab.rows, vec![vec!["Ada,Analyst", "London"]]); +} + #[test] fn text_sniff_padding_skips_and_warnings() { let dir = scratch("sniff"); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9f52184f6..0885d1554 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -100,7 +100,7 @@ fn outside_text(text: &str) -> Result { fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result { use designcraft_geom::{Point, Rect, shapes}; const LIMIT: f64 = 1.0e5; - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "spread")?; let at = super::point_param(p, "at") .filter(|a| a.x.is_finite() && a.y.is_finite()) .map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT))); @@ -169,6 +169,8 @@ pub fn specs() -> Vec { st.doc = e.doc; st.selection = e.selection; st.revision += 1; + // Pending typing overrides belong to the current edit state, not a restored snapshot. + s.typing_format = None; Ok(json!({"undone": e.label})) }), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| { @@ -178,6 +180,7 @@ pub fn specs() -> Vec { st.doc = e.doc; st.selection = e.selection; st.revision += 1; + s.typing_format = None; Ok(json!({"redone": e.label})) }), cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| { diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 6cee420ed..6bb73176b 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { if ts.cell.is_some() { return Err(bad("note.new", "notes go in story text")); } - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let author = str_param(p, "author").unwrap_or("").to_string(); let at = ts.anchor.max(ts.focus); s.edit(|d, sel| { @@ -36,7 +36,7 @@ pub fn specs() -> Vec { }), cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| { let (sid, id) = target(p)?; - let (text, author) = (str_param(p, "text").map(str::to_string), str_param(p, "author").map(str::to_string)); + let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string)); s.edit(|d, _| { let st = d.story_mut(sid).ok_or_else(|| bad("note.edit", "no such story"))?; let n = st.editorial.iter_mut().find(|n| n.id == id).ok_or_else(|| bad("note.edit", format!("no note {id}")))?; diff --git a/crates/engine/src/cmd/endnotes.rs b/crates/engine/src/cmd/endnotes.rs index bdf9ca695..664b5c10f 100644 --- a/crates/engine/src/cmd/endnotes.rs +++ b/crates/engine/src/cmd/endnotes.rs @@ -4,7 +4,7 @@ use designcraft_doc::{ParaFormat, SpreadRef, StoryId}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param}; +use super::{CommandSpec, bad, cmd, has_doc, has_text, ok}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -20,7 +20,7 @@ pub fn specs() -> Vec { ), cmd!("endnote.edit", "Edit Endnote", [], None, "{story, id, text}", has_doc, |s, p| { let (sid, id) = target(p)?; - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, _| { let style = d.endnote_options.para_style.clone(); let st = d.story_mut(sid).ok_or_else(|| bad("endnote.edit", "no such story"))?; @@ -103,7 +103,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { if st.doc.endnote_story == Some(ts.story) { return Err(bad(ID, "the endnote frame lists the endnotes")); } - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let at = ts.anchor.max(ts.focus); s.edit(|d, sel| { // The endnote frame: a text frame in the margins of a new last page. diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f47863851..8e0f04d69 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &r.bytes)?; Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) } None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), @@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result { match path { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { match p.get("path").and_then(Value::as_str) { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) } None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), @@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + crate::atomic::write_file(path, bytes) } #[cfg(target_arch = "wasm32")] fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 6ee3816fe..4b048674d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -25,9 +25,11 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save), cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state", has_doc, |s, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?.to_string(); - let bytes = to_bytes(&s.doc()?.doc); + let mut copy = (*s.doc()?.doc).clone(); + super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); + let bytes = to_bytes(©); #[cfg(not(target_arch = "wasm32"))] - std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) }), cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), @@ -37,7 +39,7 @@ pub fn specs() -> Vec { }), cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { let dir = recovery_dir(s, p)?; - Ok(json!({"saved": crate::recovery::save(s, &dir)?})) + Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) }), cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { let dir = recovery_dir(s, p)?; @@ -237,24 +239,26 @@ fn file_save(s: &mut Session, p: &Value) -> Result { .map(str::to_string) .or_else(|| st.path.clone()) .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; - { - let mut d = (*st.doc).clone(); - super::datamerge::refresh_relative_paths(&mut d, std::path::Path::new(&path)); - if d.data_merge != st.doc.data_merge { - st.doc = Arc::new(d); - st.revision = st.revision.saturating_add(1); - } - } - let bytes = to_bytes(&st.doc); + // Prepare paths for the destination without changing the live document until persistence succeeds. + let mut saved = (*st.doc).clone(); + super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); + let paths_changed = saved.data_merge != st.doc.data_merge; + let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; + let bytes = to_bytes(&saved); #[cfg(not(target_arch = "wasm32"))] - std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(&path, &bytes)?; + if paths_changed { + st.doc = saved; + st.revision = st.revision.saturating_add(1); + } st.path = Some(path.clone()); st.saved_revision = st.revision; st.saved_doc = st.doc.clone(); #[cfg(not(target_arch = "wasm32"))] - if let Some(dir) = &s.recovery_dir { - let uid = s.doc()?.uid; - crate::recovery::discard(dir, uid); + { + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); } Ok(json!({"path": path, "bytes": bytes.len()})) } @@ -331,7 +335,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let st = s.active()?; st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) }); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "file.place")?; let lid = s.doc()?.active_layer; let px = p.get("x").and_then(Value::as_f64); let py = p.get("y").and_then(Value::as_f64); @@ -454,7 +458,7 @@ fn place_load(s: &mut Session, p: &Value) -> Result { fn place_drop(s: &mut Session, p: &Value) -> Result { let (aid, (nw, nh)) = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "place.drop")?; let lid = s.doc()?.active_layer; let frame = super::id_param(p, "frame"); let rect = super::rect_param(p, "rect"); @@ -518,7 +522,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), @@ -546,7 +550,7 @@ fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Re if ids.is_empty() { return Err(bad(ID, format!("page {page} has no objects"))); } - let to = super::spread_param(p, "spread"); + let to = super::spread_param(p, ID)?; let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0), _ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0), @@ -582,7 +586,7 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad("snippet.place", "the snippet is empty")); } - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "snippet.place")?; let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0), @@ -643,15 +647,14 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { // As opened again: the fonts beside it now (unchanged files are shared, not read again). let (fonts, _, _) = load_document_fonts(&mut d, &path); let uid = s.doc()?.uid; - let st = s.doc_mut()?; + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; let mut fresh = DocState::new(d, Some(path.clone())); fresh.fonts = fonts; fresh.uid = uid; - fresh.revision = st.revision + 1; - *st = fresh; - if let Some(dir) = &s.recovery_dir { - crate::recovery::discard(dir, uid); - } + fresh.revision = s.doc()?.revision + 1; + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); + *s.doc_mut()? = fresh; Ok(json!({"path": path})) } #[cfg(target_arch = "wasm32")] @@ -735,6 +738,37 @@ mod layout_place_tests { } } +#[cfg(test)] +mod save_tests { + use serde_json::json; + + use crate::Session; + + /// Saving over an existing document replaces it whole and leaves nothing beside it. + #[test] + fn save_replaces_the_existing_file_without_leftovers() { + let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap(); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap(); + let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); + assert!(saved.stories.values().any(|st| st.text == "Saved whole")); + let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, ["doc.designcraft"], "no temporary file is left"); + // A folder that doesn't exist: an error naming the path. + let missing = dir.join("missing").join("doc.designcraft"); + let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err(); + assert!(e.to_string().contains("missing"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[cfg(test)] mod document_fonts_tests { use std::path::{Path, PathBuf}; diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 878160400..5dc0bc8ad 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -181,6 +181,7 @@ fn change(s: &mut Session, p: &Value) -> Result { over.set_json(k, v).map_err(|e| bad("find.change", e))?; } } + super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; let targets = scope(s, p)?; s.edit(|d, sel| { let mut count = 0usize; diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 47aacdc86..1111cb3b0 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide), cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete), cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| { - let r = guide_spread(p, "guide.deleteAll")?; + let r = super::spread_param(p, "guide.deleteAll")?; s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?; let mut n = 0; @@ -41,7 +41,7 @@ pub fn specs() -> Vec { }), cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| { let d = &s.doc()?.doc; - let sp = d.spread(guide_spread(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?; + let sp = d.spread(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?; let mut out = Vec::new(); for (pi, pg) in sp.pages.iter().enumerate() { for (i, g) in pg.guides.iter().enumerate() { @@ -71,23 +71,6 @@ fn optional_usize(p: &Value, key: &str, command: &str) -> Result> p.get(key).map(|value| checked_usize(value, key, command)).transpose() } -/// Numeric spread shorthand or the serialized `SpreadRef` form, without lossy/defaulting casts. -fn guide_spread(p: &Value, command: &str) -> Result { - let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) }; - if value.is_number() { - return Ok(SpreadRef::Doc(checked_usize(value, "spread", command)?)); - } - let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or a spread reference"))?; - let kind = object.get("kind").and_then(Value::as_str).ok_or_else(|| bad(command, "`spread.kind` must be `doc` or `parent`"))?; - let index = object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?; - let index = checked_usize(index, "spread.index", command)?; - match kind { - "doc" => Ok(SpreadRef::Doc(index)), - "parent" => Ok(SpreadRef::Parent(index)), - _ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")), - } -} - fn finite_number(p: &Value, key: &str, command: &str) -> Result> { let Some(value) = p.get(key) else { return Ok(None) }; let number = value.as_f64().ok_or_else(|| bad(command, format!("`{key}` must be a finite number")))?; @@ -113,7 +96,7 @@ fn page_at(d: &Document, r: SpreadRef, x: f64) -> usize { fn add(s: &mut Session, p: &Value) -> Result { let o = orientation(p)?; let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?; - let r = guide_spread(p, "guide.add")?; + let r = super::spread_param(p, "guide.add")?; let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); let d = &s.doc()?.doc; let pi = match optional_usize(p, "page", "guide.add")? { @@ -139,7 +122,7 @@ fn add(s: &mut Session, p: &Value) -> Result { fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> { let pi = optional_usize(p, "page", cmd)?.ok_or_else(|| bad(cmd, "missing page"))?; let i = optional_usize(p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))?; - Ok((guide_spread(p, cmd)?, pi, i)) + Ok((super::spread_param(p, cmd)?, pi, i)) } /// Commands must use the same guide/layer protection as pointer editing. @@ -237,7 +220,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result { let cg = guide_gutter(p, "columnGutter")?; let to_page = str_param(p, "fitTo") == Some("page"); let remove = p.get("removeExisting").and_then(Value::as_bool).unwrap_or(false); - let r = guide_spread(p, "layout.createGuides")?; + let r = super::spread_param(p, "layout.createGuides")?; let only = optional_usize(p, "page", "layout.createGuides")?; let page_count = { let sp = s.doc()?.doc.spread(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?; diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index a0af09f5f..96498e863 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -25,7 +25,7 @@ fn export_idml(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index f0e77af56..384b4f827 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -29,7 +29,7 @@ impl Library { #[cfg(not(target_arch = "wasm32"))] if let Some(p) = &self.path { let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?; - std::fs::write(p, bytes).map_err(|e| crate::EngineError::Other(format!("{p}: {e}")))?; + crate::atomic::write_file(p, &bytes)?; } Ok(()) } @@ -86,10 +86,12 @@ pub fn specs() -> Vec { let n = st.selection.items.len(); (str_param(p, "name").map(str::to_string).unwrap_or_else(|| if n > 1 { format!("{n} objects") } else { kind.clone() }), str_param(p, "description").unwrap_or("").to_string()) }; - let lib = s.library.as_mut().ok_or_else(no_library)?; + let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone(); + let index = lib.items.len(); lib.items.push(LibraryItem { name, description, snippet: super::file::base64_encode(&snip) }); lib.save()?; - Ok(json!({"index": lib.items.len() - 1})) + s.library = Some(lib); + Ok(json!({"index": index})) }), cmd!( "library.place", @@ -107,13 +109,14 @@ pub fn specs() -> Vec { } ), cmd!(noundo "library.remove", "Delete Item", [], None, "{index}", has_library, |s, p| { - let lib = s.library.as_mut().ok_or_else(no_library)?; + let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone(); let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("library.remove", "`index` required"))? as usize; if i >= lib.items.len() { return Err(bad("library.remove", format!("no item {i}"))); } lib.items.remove(i); lib.save()?; + s.library = Some(lib); ok() }), cmd!(noundo "conveyor.collect", "Collect", [], None, "{ids? (default: the selection)} — each object onto the Content Collector conveyor → {count}", has_selection, |s, p| { diff --git a/crates/engine/src/cmd/linked.rs b/crates/engine/src/cmd/linked.rs index ec07a24b2..f504fe661 100644 --- a/crates/engine/src/cmd/linked.rs +++ b/crates/engine/src/cmd/linked.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { |s, p| { let parent = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.placeAndLink", "missing story"))?); let rect = rect_param(p, "rect").ok_or_else(|| bad("story.placeAndLink", "missing rect"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "story.placeAndLink")?; let layer = s.doc()?.active_layer; s.edit(|d, sel| { if d.story(parent).is_none() { diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 604369df6..879fdb368 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -168,7 +168,7 @@ pub fn specs() -> Vec { } ), cmd!("guide.liquid", "Convert to Liquid Guide", [], None, "{spread?, page, index, on?: bool (default true)}", has_doc, |s, p| { - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "guide.liquid")?; let pi = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize; let gi = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("guide.liquid", "`index` required"))? as usize; let on = p.get("on").and_then(Value::as_bool).unwrap_or(true); diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index ec90d52e3..d9ce51148 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -70,7 +70,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { }); } let text = s.doc()?.selection.text.filter(|t| t.cell.is_none()); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "math.insert")?; let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); s.edit(|d, sel| { if let Some(t) = text { diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index b6e4cd4b8..f6d13ec0b 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -20,7 +20,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let st = s.active()?; st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) }); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "file.place")?; let lid = s.doc()?.active_layer; let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); let mime = mime.to_string(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d4b0290a9..affe07126 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -256,12 +256,23 @@ pub(crate) fn rect_param(p: &Value, key: &str) -> Option { let f = |i: usize| a.get(i).and_then(Value::as_f64); Some(Rect::new(f(0)?, f(1)?, f(2)?, f(3)?)) } -/// `{"kind":"doc","index":0}`, `0` or absent (= spread 0). -pub(crate) fn spread_param(p: &Value, key: &str) -> SpreadRef { - match p.get(key) { - Some(Value::Number(n)) => SpreadRef::Doc(n.as_u64().unwrap_or(0) as usize), - Some(v) => serde_json::from_value(v.clone()).unwrap_or(SpreadRef::Doc(0)), - None => SpreadRef::Doc(0), +/// The `spread` param: a body spread index (`0`), a spread reference (`{"kind":"doc"|"parent","index":0}`) +/// or absent (= spread 0). Anything else is an error: read as spread 0, a script aiming at a parent +/// spread got its items on the first page without a word. +pub(crate) fn spread_param(p: &Value, command: &str) -> Result { + let index = |v: &Value, key: &str| { + v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer"))) + }; + let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) }; + if value.is_number() { + return Ok(SpreadRef::Doc(index(value, "spread")?)); + } + let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or {kind: doc|parent, index}"))?; + let i = index(object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?, "spread.index")?; + match object.get("kind").and_then(Value::as_str) { + Some("doc") => Ok(SpreadRef::Doc(i)), + Some("parent") => Ok(SpreadRef::Parent(i)), + _ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")), } } diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index 1ef8d9954..e3b3ccc52 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -3,7 +3,7 @@ use designcraft_doc::{CellAddr, FootnoteOptions, ParaFormat, StoryId, TextSel}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use super::{CommandSpec, bad, cmd, has_doc}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -58,7 +58,7 @@ fn in_note(s: &Session) -> std::result::Result<(), String> { } fn insert(s: &mut Session, p: &Value) -> Result { - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("footnote.insert", "no insertion point"))?; let para = ParaFormat { style: d.footnote_options.para_style.clone(), ..Default::default() }; @@ -130,7 +130,7 @@ fn list(s: &mut Session, p: &Value) -> Result { fn set_text(s: &mut Session, p: &Value) -> Result { let sid = story_id(p, "footnote.setText")?; let id = note_id(p, "footnote.setText")?; - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, _| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let n = st.note_mut(id).ok_or_else(|| bad("footnote.setText", format!("no footnote {id}")))?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82e042ae2..5e9ed3912 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -14,6 +14,17 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_doc, has_selection, ok, point_param, rect_param, spread_param, str_param, targets}; use crate::{Result, Session}; +/// `name` must be a swatch of the document: an unknown name would paint nothing. The error names +/// the swatch it was probably meant to be (`Paper` → `[Paper]`, a different case). +fn known_swatch(doc: &designcraft_doc::Document, name: &str, cmd: &str, key: &str) -> Result<()> { + if doc.swatch(name).is_some() { + return Ok(()); + } + let bare = |n: &str| n.trim().trim_start_matches('[').trim_end_matches(']').to_lowercase(); + let hint = doc.swatches.iter().find(|s| bare(&s.name) == bare(name)).map(|s| format!(" (did you mean `{}`?)", s.name)).unwrap_or_default(); + Err(bad(cmd, format!("{key}: no swatch named `{name}`{hint}; create one with `swatch.create` and use its returned name"))) +} + pub fn specs() -> Vec { vec![ cmd!( @@ -21,12 +32,20 @@ pub fn specs() -> Vec { "Create Frame", [], None, - "{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}", + "{spread?: index | {kind: doc|parent, index} (a parent spread: index into `parents` of document.inspect), rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}", has_doc, frame_create ), cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create), - cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move), + cmd!( + "transform.move", + "Move", + ["Object", "Transform"], + None, + "{dx, dy, copy?: bool, ids?, toSpread?} → {moved, ids: the objects moved (with copy, the copies)}", + has_selection, + transform_move + ), cmd!( "transform.resize", "Resize", @@ -160,11 +179,30 @@ pub fn specs() -> Vec { ) } ), - cmd!("object.fill", "Fill", [], None, "{swatch, tint?: 0..1, ids?}", has_selection, |s, p| { + cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| { let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string(); + known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?; let tint = f64_or(p, "tint", 1.0) as f32; set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false) }), + cmd!( + "object.swapFillStroke", + "Swap Fill and Stroke", + [], + None, + "{ids?} — each object's fill and stroke colours (and tints) trade places; locked objects stay as they are", + has_editable_selection, + |s, p| edit_unlocked(s, p, swap_fill_stroke) + ), + cmd!( + "object.defaultFillStroke", + "Default Fill and Stroke", + [], + None, + "{ids?} — no fill and a black stroke; locked objects stay as they are", + has_editable_selection, + |s, p| edit_unlocked(s, p, default_fill_stroke) + ), cmd!( "object.stroke", "Stroke", @@ -173,6 +211,11 @@ pub fn specs() -> Vec { "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}", has_selection, |s, p| { + for key in ["swatch", "gapSwatch"] { + if let Some(name) = p.get(key).and_then(Value::as_str) { + known_swatch(&s.doc()?.doc, name, "object.stroke", key)?; + } + } let p2 = p.clone(); set_flag( s, @@ -565,13 +608,39 @@ pub fn specs() -> Vec { "Step and Repeat…", ["Edit"], Some("Cmd+Alt+U"), - "{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents", + "{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents → {created, ids: the copies}", has_selection, step_and_repeat ), cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create), - cmd!("path.appendAnchor", "Add Anchor", [], None, "{id, anchor: {p, in?, out?}} (spread coords)", has_doc, path_append), - cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id}", has_doc, path_close), + cmd!( + "path.appendAnchor", + "Add Anchor", + [], + None, + "{id, anchor: {p, in?, out?} (spread coords), subpath?: index (default the last)} — after the subpath's last anchor", + has_doc, + path_append + ), + cmd!( + "path.setAnchor", + "Set Anchor", + [], + None, + "{id, subpath?: index (default the last), anchor: index|\"first\"|\"last\", p?, in?, out? (spread coords)} — moves an anchor or its direction lines; with both lines given and collinear it becomes smooth", + has_doc, + path_set_anchor + ), + cmd!( + "path.reverse", + "Reverse Path", + ["Object", "Paths"], + None, + "{ids?, subpath?: index (only that subpath of the first target)} — the anchors run the other way (the end point becomes the start)", + has_selection, + path_reverse + ), + cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close), cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors), cmd!( "object.align", @@ -803,7 +872,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.create", "missing rect"))?; let shape = str_param(p, "shape").unwrap_or("rectangle"); let content = str_param(p, "content").unwrap_or("graphic"); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "frame.create")?; let lid = s.doc()?.active_layer; let text = super::text_param(p, "text"); let caret = bool_or(p, "caret", content == "text"); @@ -866,7 +935,7 @@ pub(crate) fn polygon_in(r: Rect, sides: u32, inset: f64) -> designcraft_geom::P fn line_create(s: &mut Session, p: &Value) -> Result { let a = point_param(p, "a").ok_or_else(|| bad("line.create", "missing a"))?; let b = point_param(p, "b").ok_or_else(|| bad("line.create", "missing b"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "line.create")?; let lid = s.doc()?.active_layer; s.edit(|d, sel| { let id = ItemId(d.alloc()); @@ -981,7 +1050,7 @@ fn transform_move(s: &mut Session, p: &Value) -> Result { } it.xf = Affine::translate((dx, dy)) * it.xf; } - Ok(json!({"moved": ids.len()})) + Ok(json!({"moved": ids.len(), "ids": ids.iter().map(|i| i.0).collect::>()})) }) } @@ -1293,6 +1362,43 @@ fn arrange(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let to = str_param(p, "to").unwrap_or("front").to_string(); s.edit(|d, _| { + if matches!(to.as_str(), "forward" | "backward") { + let selected: HashSet = ids.iter().copied().collect(); + let mut positions: Vec<_> = selected + .iter() + .filter_map(|id| { + let loc = d.find(*id)?; + match loc.path.as_slice() { + [i] => Some((loc.spread, *i)), + _ => None, + } + }) + .collect(); + // Move the leading selected items first, independently of selection/click order. + // Swapping only with unselected siblings leaves the other selected indices valid. + positions.sort_by_key(|(_, i)| *i); + let forward = to == "forward"; + if forward { + positions.reverse(); + } + for (spread, i) in positions { + let Some(sp) = d.spread_mut(spread) else { continue }; + let Some(it) = sp.items.get(i) else { continue }; + let layer = it.layer; + let sibling = if forward { + sp.items.iter().enumerate().skip(i.saturating_add(1)).find(|(_, it)| it.layer == layer) + } else { + sp.items.iter().enumerate().take(i).rev().find(|(_, it)| it.layer == layer) + }; + if let Some((j, it)) = sibling + && !selected.contains(&it.id) + { + // Keep every other layer's slots and relative stacking order untouched. + sp.items.swap(i, j); + } + } + return ok(); + } for id in &ids { let Some(loc) = d.find(*id) else { continue }; if loc.path.len() != 1 { @@ -1305,7 +1411,6 @@ fn arrange(s: &mut Session, p: &Value) -> Result { let j = match to.as_str() { "front" => n - 1, "back" => 0, - "forward" => (i + 1).min(n - 1), _ => i.saturating_sub(1), }; sp.items.insert(j, it); @@ -1379,6 +1484,60 @@ fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) - }) } +/// An object a colour command may change: neither it nor its layer is locked. +fn unlocked(d: &Document, it: &Item) -> bool { + !it.locked && d.layer(it.layer).is_none_or(|l| !l.locked) +} + +fn has_editable_selection(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?; + if st.selection.items.iter().any(|id| st.doc.item(*id).is_some_and(|it| unlocked(&st.doc, it))) { + Ok(()) + } else { + Err("the selected objects are locked".into()) + } +} + +/// Runs `f` on each unlocked target in one edit (one undo step). +fn edit_unlocked(s: &mut Session, p: &Value, f: impl Fn(&mut Item)) -> Result { + let ids = targets(s, p)?; + s.edit(|d, _| { + let mut changed = 0; + for id in &ids { + if d.item(*id).is_some_and(|it| unlocked(d, it)) + && let Some(it) = d.item_mut(*id) + { + f(it); + changed += 1; + } + } + Ok(json!({"changed": changed})) + }) +} + +/// A stroke that gets a colour needs a weight to show it. +fn visible_weight(st: &mut Stroke) { + if st.swatch != designcraft_color::swatch::NONE && st.weight <= 0.0 { + st.weight = Stroke::default().weight; + } +} + +fn swap_fill_stroke(it: &mut Item) { + let fill = std::mem::take(&mut it.fill); + it.fill = Fill { swatch: std::mem::take(&mut it.stroke.swatch), tint: it.stroke.tint, ..fill.clone() }; + it.stroke.swatch = fill.swatch; + it.stroke.tint = fill.tint; + visible_weight(&mut it.stroke); +} + +fn default_fill_stroke(it: &mut Item) { + it.fill = Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, ..it.fill.clone() }; + it.stroke.swatch = designcraft_color::swatch::BLACK.into(); + it.stroke.tint = 1.0; + visible_weight(&mut it.stroke); +} + fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result { s.edit(|d, _| { for i in 0..d.spreads.len() { @@ -1567,7 +1726,7 @@ fn gap_move(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_array) .and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))) .ok_or_else(|| bad(ID, "missing at"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, ID)?; let (vertical, a, z, before, after) = find_gap(&s.doc()?.doc, sr, at).ok_or_else(|| bad(ID, "no gap there"))?; // `delta` along the gap's axis, or a drag (dx, dy) of which the axis part counts. let delta = @@ -1862,6 +2021,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result { for ((id, r), t) in items.iter().zip(targets) { let delta = t - lo(r); if let Some(it) = d.item_mut(*id) { + // Keep locked items in the spacing calculation, as align does for its reference. + if it.locked { + continue; + } let v = if horizontal { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }; it.xf = Affine::translate(v) * it.xf; } @@ -1894,7 +2057,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result { return Err(bad("path.create", "a path needs at least two anchors")); } let closed = bool_or(p, "closed", false); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "path.create")?; let lid = s.doc()?.active_layer; s.edit(|d, sel| { let id = ItemId(d.alloc()); @@ -1910,11 +2073,20 @@ fn path_create(s: &mut Session, p: &Value) -> Result { fn path_append(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; + let subpath = p.get("subpath").and_then(Value::as_u64); s.edit(|d, _| { let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let sp = it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?; + let sp = match subpath { + Some(i) => { + usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))? + } + None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?, + }; + if sp.closed { + return Err(bad("path.appendAnchor", "the subpath is closed")); + } sp.anchors.push(a.transform(inv)); if it.shape == Shape::GraphicLine { it.shape = Shape::Path; @@ -1923,12 +2095,103 @@ fn path_append(s: &mut Session, p: &Value) -> Result { }) } +fn path_set_anchor(s: &mut Session, p: &Value) -> Result { + const ID: &str = "path.setAnchor"; + let id = super::id_param(p, "id").ok_or_else(|| bad(ID, "missing id"))?; + let pt = |k: &str| -> Option { + let a = p.get(k)?.as_array()?; + let q = Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?); + (q.x.is_finite() && q.y.is_finite()).then_some(q) + }; + let (new_p, new_in, new_out) = (pt("p"), pt("in"), pt("out")); + if new_p.is_none() && new_in.is_none() && new_out.is_none() { + return Err(bad(ID, "give p, in or out")); + } + let subpath = p.get("subpath").and_then(Value::as_u64); + let which = p.get("anchor").cloned().unwrap_or(Value::Null); + s.edit(|d, _| { + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + if it.locked { + return Err(bad(ID, "the path is locked")); + } + let sp = match subpath { + Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)), + None => it.path.subpaths.last_mut(), + } + .ok_or_else(|| bad(ID, "no such subpath"))?; + let n = sp.anchors.len(); + let ai = match &which { + Value::String(w) if w == "first" => 0, + Value::String(w) if w == "last" => n.saturating_sub(1), + v => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "anchor: an index, \"first\" or \"last\""))?, + }; + let a = sp.anchors.get_mut(ai).ok_or_else(|| bad(ID, "no such anchor"))?; + if let Some(q) = new_p { + let v = inv * q - a.p; + a.p += v; + a.h_in += v; + a.h_out += v; + } + if let Some(q) = new_in { + a.h_in = inv * q; + } + if let Some(q) = new_out { + a.h_out = inv * q; + } + if new_in.is_some() && new_out.is_some() { + let (i, o) = (a.h_in - a.p, a.h_out - a.p); + let collinear = (i.x * o.y - i.y * o.x).abs() <= 1e-6 * i.hypot().max(1.0) * o.hypot().max(1.0) && i.dot(o) <= 0.0; + a.kind = if collinear { designcraft_geom::AnchorKind::Smooth } else { designcraft_geom::AnchorKind::Corner }; + } + ok() + }) +} + +fn path_reverse(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); + if let (Some(i), Some(id)) = (only, ids.first()) + && s.doc()?.doc.item(*id).is_none_or(|it| it.path.subpaths.get(i).is_none()) + { + return Err(bad("path.reverse", "no such subpath")); + } + let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + s.edit(|d, _| { + let mut reversed = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + if it.locked || it.is_group() { + continue; + } + for (i, sp) in it.path.subpaths.iter_mut().enumerate() { + if only.is_some_and(|o| o != i) { + continue; + } + sp.anchors.reverse(); + for a in &mut sp.anchors { + std::mem::swap(&mut a.h_in, &mut a.h_out); + } + } + reversed += 1; + } + Ok(json!({"reversed": reversed})) + }) +} + fn path_close(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); s.edit(|d, _| { let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - for sp in &mut it.path.subpaths { - sp.closed = true; + if only.is_some_and(|i| i >= it.path.subpaths.len()) { + return Err(bad("path.close", "no such subpath")); + } + for (i, sp) in it.path.subpaths.iter_mut().enumerate() { + if only.is_none_or(|o| o == i) { + sp.closed = true; + } } if it.shape == Shape::GraphicLine { it.shape = Shape::Path; @@ -2091,14 +2354,195 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result { } } *sel = Selection::items(all.clone()); - Ok(json!({"created": all.len() - ids.len()})) + let copies: Vec = all.iter().skip(ids.len()).map(|i| i.0).collect(); + Ok(json!({"created": copies.len(), "ids": copies})) }) } +#[cfg(test)] +mod arrange_tests { + use super::*; + + fn fixture(layers: [usize; N]) -> (Session, [ItemId; N]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let first = s.doc().unwrap().doc.default_layer().0; + let second = s.execute("layer.new", &json!({"name": "Other layer"})).unwrap()["id"].as_u64().unwrap(); + let layer_ids = [first, second]; + let mut ids = [ItemId(0); N]; + for (id, layer) in ids.iter_mut().zip(layers) { + // Overlap objects within each layer; separate layers spatially for hit testing. + let x = if layer == 0 { 0 } else { 200 }; + *id = ItemId(s.execute("frame.create", &json!({"rect": [x, 0, x + 100, 100]})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.setLayer", &json!({"ids": [id.0], "layer": layer_ids[layer]})).unwrap(); + } + (s, ids) + } + + fn stack(s: &Session) -> Vec { + s.doc().unwrap().doc.spreads[0].items.iter().map(|it| it.id).collect() + } + + fn snapshot(s: &Session) -> Value { + let st = s.doc().unwrap(); + json!({"document": &*st.doc, "selection": &st.selection}) + } + + fn assert_items_unchanged(before: &Document, after: &Document) { + assert_eq!(serde_json::to_value(&before.layers).unwrap(), serde_json::to_value(&after.layers).unwrap()); + for it in &before.spreads[0].items { + assert_eq!(serde_json::to_value(it).unwrap(), serde_json::to_value(after.item(it.id).unwrap()).unwrap()); + } + } + + #[test] + fn arrange_steps_past_other_layers_change_hit_order_and_undo() { + for direction in ["forward", "backward"] { + let (mut s, [a, x, y, b]) = fixture([0, 1, 1, 0]); + let selected = if direction == "forward" { a } else { b }; + s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap(); + let before = snapshot(&s); + let doc = s.doc().unwrap().doc.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let hit = |s: &Session| s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0); + assert_eq!(hit(&s), Some(b)); + + s.execute("object.arrange", &json!({"to": direction})).unwrap(); + assert_eq!(stack(&s), [b, x, y, a], "{direction}"); + assert_eq!(hit(&s), Some(a), "the visible topmost object must change"); + assert_items_unchanged(&doc, &s.doc().unwrap().doc); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection.items, [selected]); + let after = snapshot(&s); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(snapshot(&s), before); + assert_eq!(hit(&s), Some(b)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(snapshot(&s), after); + assert_eq!(hit(&s), Some(a)); + } + } + + #[test] + fn arrange_multiple_selection_preserves_order_independent_of_input_order() { + // Layer 0: a, b, c, d. Layer 1: x, y. Arrays name the original stack indices. + let cases = [ + ("forward", [0, 2], [4, 1, 0, 3, 2, 5]), // a,b cross c as a block + ("backward", [2, 4], [2, 1, 4, 3, 0, 5]), // b,c cross a as a block + ("forward", [0, 4], [2, 1, 0, 3, 5, 4]), // separate selected blocks + ("backward", [2, 5], [2, 1, 0, 3, 5, 4]), + ("forward", [4, 5], [0, 1, 2, 3, 4, 5]), // front block is already at its endpoint + ("backward", [0, 2], [0, 1, 2, 3, 4, 5]), // back block is already at its endpoint + ]; + for (direction, selected, expected) in cases { + for reverse in [false, true] { + let (mut s, ids) = fixture([0, 1, 0, 1, 0, 0]); + let doc = s.doc().unwrap().doc.clone(); + let mut selected = selected.map(|i| ids[i].0).to_vec(); + if reverse { + selected.reverse(); + } + // Explicit command targets may contain duplicates: an item still moves once. + selected.push(selected[0]); + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), expected.map(|i| ids[i]), "{direction}, reverse={reverse}"); + assert_items_unchanged(&doc, &s.doc().unwrap().doc); + } + } + } + + #[test] + fn arrange_same_layer_steps_stop_at_layer_endpoints() { + let cases = [ + ("forward", vec![1], [0, 2, 1, 3, 4]), + ("backward", vec![3], [0, 1, 3, 2, 4]), + ("forward", vec![3], [0, 1, 2, 3, 4]), + ("backward", vec![1], [0, 1, 2, 3, 4]), + ("forward", vec![3, 1, 2], [0, 1, 2, 3, 4]), + ("backward", vec![3, 1, 2], [0, 1, 2, 3, 4]), + ]; + for (direction, selected, expected) in cases { + let (mut s, ids) = fixture([1, 0, 0, 0, 1]); + let selected: Vec<_> = selected.into_iter().map(|i| ids[i].0).collect(); + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), expected.map(|i| ids[i])); + } + for direction in ["forward", "backward"] { + let (mut s, [x, a, y]) = fixture([1, 0, 1]); + s.execute("object.arrange", &json!({"to": direction, "ids": [a.0]})).unwrap(); + assert_eq!(stack(&s), [x, a, y], "only object on its layer"); + } + } + + #[test] + fn arrange_selection_on_two_layers_moves_each_layer_independently() { + for direction in ["forward", "backward"] { + let (mut s, [a, x, b, y]) = fixture([0, 1, 0, 1]); + let selected = if direction == "forward" { [x.0, a.0] } else { [b.0, y.0] }; + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), [b, y, a, x]); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a)); + assert_eq!(doc.hit_item(0, Point::new(250.0, 50.0), 0.0), Some(x)); + } + } + + #[test] + fn arrange_menu_steps_use_same_layer_order() { + for command in ["object.bringForward", "object.sendBackward"] { + let (mut s, [a, x, b]) = fixture([0, 1, 0]); + let selected = if command == "object.bringForward" { a } else { b }; + s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap(); + s.execute(command, &json!({})).unwrap(); + assert_eq!(stack(&s), [b, x, a]); + assert_eq!(s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a)); + } + } +} + #[cfg(test)] mod step_repeat_tests { use super::*; + #[test] + fn swap_and_default_fill_stroke_are_one_undo_step_per_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = |v: Value| ItemId(v["id"].as_u64().unwrap()); + let a = id(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()); + let b = id(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()); + let locked = id(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap()); + s.execute("object.fill", &json!({"ids": [a.0], "swatch": "[Paper]", "tint": 0.5})).unwrap(); + s.execute("object.stroke", &json!({"ids": [a.0], "swatch": "[Black]", "weight": 2})).unwrap(); + s.execute("object.fill", &json!({"ids": [b.0, locked.0], "swatch": "[Black]"})).unwrap(); + s.execute("object.stroke", &json!({"ids": [b.0, locked.0], "swatch": "[None]"})).unwrap(); + s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap(); + s.execute("selection.set", &json!({"ids": [a.0, b.0, locked.0]})).unwrap(); + let colors = |s: &Session, i: ItemId| { + let it = s.doc().unwrap().doc.item(i).unwrap().clone(); + (it.fill.swatch, it.fill.tint, it.stroke.swatch, it.stroke.tint) + }; + let before = [colors(&s, a), colors(&s, b), colors(&s, locked)]; + + assert_eq!(s.execute("object.swapFillStroke", &json!({})).unwrap()["changed"], 2); + assert_eq!(colors(&s, a), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5)); + assert_eq!(colors(&s, b), ("[None]".into(), 1.0, "[Black]".into(), 1.0)); + assert_eq!(colors(&s, locked), before[2], "a locked object keeps its colours"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before, "one undo restores both"); + + s.execute("object.defaultFillStroke", &json!({})).unwrap(); + for i in [a, b] { + assert_eq!(colors(&s, i), ("[None]".into(), 1.0, "[Black]".into(), 1.0)); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before); + + s.execute("selection.set", &json!({"ids": [locked.0]})).unwrap(); + assert!(s.execute("object.swapFillStroke", &json!({})).is_err(), "nothing unlocked to change"); + } + #[test] fn step_repeat_validates_counts_and_offsets() { assert_eq!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES})).unwrap().len(), MAX_STEP_REPEAT_COPIES as usize); @@ -2163,7 +2607,7 @@ mod scale_tests { s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap(); let id = ItemId(r["id"].as_u64().unwrap()); - s.execute("object.stroke", &json!({"weight": 4, "swatch": "Black"})).unwrap(); + s.execute("object.stroke", &json!({"weight": 4, "swatch": "[Black]"})).unwrap(); let weight = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().stroke.weight; let bounds = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().bounds(); s.execute("transform.scale", &json!({"sx": 2.0})).unwrap(); @@ -2578,6 +3022,18 @@ mod line_end_tests { s.execute("text.insert", &json!({"text": "\rFour", "raw": true})).unwrap(); assert_eq!(story(&s).text, "One\nTwo\nThree\nFour"); assert_eq!(story(&s).paras.len(), 4); + // Editing a range or the whole story (scripts written for InDesign end paragraphs with \r). + s.execute("story.replaceRange", &json!({"story": sid.0, "start": 0, "end": 3, "text": "Zero\rOne"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("Zero\nOne\nTwo\nThree\nFour", 5)); + s.execute("story.setText", &json!({"story": sid.0, "text": "A\r\nB\rC"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\nB\nC", 3)); + // Notes, made and edited. + s.execute("text.select", &json!({"story": sid.0, "anchor": 1})).unwrap(); + let n = s.execute("note.new", &json!({"text": "One\rTwo"})).unwrap(); + let note = |s: &mut Session| s.execute("note.list", &json!({})).unwrap()[0]["text"].clone(); + assert_eq!(note(&mut s), "One\nTwo"); + s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap(); + assert_eq!(note(&mut s), "Three\nFour"); } } @@ -2726,3 +3182,148 @@ mod blend_mode_tests { assert_eq!(it.opacity, 1.0); } } + +#[cfg(test)] +mod copy_ids_tests { + use serde_json::json; + + #[test] + fn copies_report_their_ids() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let ids = |r: &serde_json::Value| r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect::>(); + let selected = |s: &crate::Session| s.doc().unwrap().selection.items.iter().map(|i| i.0).collect::>(); + // Moving reports what moved; copying reports the copies, which are selected. + assert_eq!(ids(&s.execute("transform.move", &json!({"dx": 5, "dy": 0, "ids": [a]})).unwrap()), [a]); + let r = s.execute("transform.move", &json!({"dx": 100, "dy": 0, "copy": true, "ids": [a]})).unwrap(); + let copy = ids(&r); + assert!(copy.len() == 1 && copy[0] != a && s.doc().unwrap().doc.item(designcraft_doc::ItemId(copy[0])).is_some(), "{r}"); + assert_eq!(selected(&s), copy); + // Edit › Duplicate. + let r = s.execute("edit.duplicate", &json!({})).unwrap(); + let dup = ids(&r); + assert!(dup.len() == 1 && dup[0] != copy[0], "{r}"); + assert_eq!(selected(&s), dup); + // Step and Repeat: every copy. + let r = s.execute("edit.stepAndRepeat", &json!({"count": 3, "dx": 0, "dy": 60, "ids": [a]})).unwrap(); + assert_eq!((r["created"].as_u64(), ids(&r).len()), (Some(3), 3), "{r}"); + assert!(ids(&r).iter().all(|i| s.doc().unwrap().doc.item(designcraft_doc::ItemId(*i)).is_some())); + } +} + +#[cfg(test)] +mod swatch_name_tests { + use serde_json::json; + + #[test] + fn fills_and_strokes_must_name_a_swatch_and_change_nothing_otherwise() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone(); + for (cmd, params, hint) in [ + ("object.fill", json!({"swatch": "Paper", "ids": [id]}), Some("[Paper]")), + ("object.fill", json!({"swatch": "[black]", "ids": [id]}), Some("[Black]")), + ("object.fill", json!({"swatch": "Missing Ink", "ids": [id]}), None), + ("object.stroke", json!({"swatch": "#ff0000", "weight": 4, "ids": [id]}), None), + ("object.stroke", json!({"gapSwatch": "paper", "ids": [id]}), Some("[Paper]")), + ] { + let before = s.doc().unwrap().doc.clone(); + let e = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(e.contains("no swatch named") && e.contains("swatch.create"), "{cmd}: {e}"); + if let Some(h) = hint { + assert!(e.contains(&format!("did you mean `{h}`")), "{cmd}: {e}"); + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{cmd}: nothing changed"); + } + // Real names, [None] included, still work. + for sw in ["[Paper]", "[None]", "C=100 M=0 Y=0 K=0"] { + s.execute("object.fill", &json!({"swatch": sw, "ids": [id]})).unwrap(); + s.execute("object.stroke", &json!({"swatch": sw, "ids": [id]})).unwrap(); + } + } +} + +#[cfg(test)] +mod path_edit_tests { + use designcraft_doc::ItemId; + use designcraft_geom::Point; + use serde_json::json; + + fn session_with_path() -> (crate::Session, u64) { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s + .execute( + "path.create", + &json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100], "in": [170, 80], "out": [230, 120]}, {"p": [300, 200]}]}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + (s, id) + } + + fn anchors(s: &crate::Session, id: u64) -> Vec { + let d = &s.doc().unwrap().doc; + let it = d.item(ItemId(id)).unwrap(); + let xf = d.parent_xf(&d.find(ItemId(id)).unwrap()) * it.xf; + it.path.subpaths[0].anchors.iter().map(|a| a.transform(xf)).collect() + } + + #[test] + fn reverse_path_swaps_ends_and_direction_lines() { + let (mut s, id) = session_with_path(); + s.execute("path.reverse", &json!({"ids": [id]})).unwrap(); + let a = anchors(&s, id); + assert_eq!(a.iter().map(|a| a.p).collect::>(), [Point::new(300.0, 200.0), Point::new(200.0, 100.0), Point::new(100.0, 100.0)]); + assert_eq!((a[1].h_in, a[1].h_out), (Point::new(230.0, 120.0), Point::new(170.0, 80.0))); + assert!(s.execute("path.reverse", &json!({"ids": [id], "subpath": 3})).is_err()); + } + + #[test] + fn anchors_go_on_the_named_open_subpath_and_set_anchor_reshapes_one() { + let (mut s, id) = session_with_path(); + s.execute("path.appendAnchor", &json!({"id": id, "subpath": 0, "anchor": {"p": [400, 250]}})).unwrap(); + assert_eq!(anchors(&s, id).last().unwrap().p, Point::new(400.0, 250.0)); + assert!(s.execute("path.appendAnchor", &json!({"id": id, "subpath": 1, "anchor": {"p": [0, 0]}})).is_err()); + // Direction lines on the last anchor: collinear and opposite make it smooth. + s.execute("path.setAnchor", &json!({"id": id, "anchor": "last", "in": [380, 230], "out": [420, 270]})).unwrap(); + let last = *anchors(&s, id).last().unwrap(); + assert_eq!((last.h_in, last.h_out, last.kind), (Point::new(380.0, 230.0), Point::new(420.0, 270.0), designcraft_geom::AnchorKind::Smooth)); + s.execute("path.setAnchor", &json!({"id": id, "anchor": 0, "p": [90, 110]})).unwrap(); + assert_eq!(anchors(&s, id)[0].p, Point::new(90.0, 110.0)); + assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": 9, "p": [0, 0]})).is_err()); + assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": "last"})).is_err(), "nothing to set"); + // Closing one subpath; then it takes no more anchors. + s.execute("path.close", &json!({"id": id, "subpath": 0})).unwrap(); + assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err()); + assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err()); + } +} + +#[cfg(test)] +mod spread_target_tests { + use super::*; + + #[test] + fn creating_on_a_parent_spread_and_rejecting_unknown_spreads() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let parent = json!({"kind": "parent", "index": 0}); + let r = s.execute("frame.create", &json!({"spread": parent, "rect": [0, 0, 100, 100]})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.find(id).unwrap().spread, SpreadRef::Parent(0)); + s.execute("line.create", &json!({"spread": parent, "a": [0, 0], "b": [50, 50]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].items.len(), 2); + + // Each of these used to land on the first body page. + for bad in + [json!("A-Parent"), json!("0"), json!(-1), json!(1.5), json!({"kind": "master", "index": 0}), json!({"kind": "parent"}), json!([0])] + { + assert!(s.execute("frame.create", &json!({"spread": bad, "rect": [0, 0, 100, 100]})).is_err(), "frame.create spread {bad}"); + assert!(s.execute("line.create", &json!({"spread": bad, "a": [0, 0], "b": [50, 50]})).is_err(), "line.create spread {bad}"); + } + assert!(s.doc().unwrap().doc.spreads[0].items.is_empty()); + } +} diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 77bbf87ab..ffd852f97 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Type on a Path Options…", ["Type", "Type on a Path"], None, - "{id?, start?, flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)}", + "{id?, start?, end?: distance along the path or null (the path's end), flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)} — start and end are the brackets: start ≤ end, both on the path", has_doc, path_options ), @@ -36,7 +36,7 @@ fn align_param(p: &Value) -> Result> { fn on_path(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("type.onPath", "missing id"))?; - let text = super::str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let mut pt = PathType { start: p.get("start").and_then(Value::as_f64).unwrap_or(0.0).max(0.0), flip: p.get("flip").and_then(Value::as_bool).unwrap_or(false), @@ -86,6 +86,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result { let p = p.clone(); s.edit(|d, sel| { let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let len = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length(); let Content::Text(tf) = &mut it.content else { return Err(bad("type.pathOptions", "not type on a path")) }; let Some(pt) = tf.options.path.as_mut() else { return Err(bad("type.pathOptions", "not type on a path")) }; if delete { @@ -95,8 +96,26 @@ fn path_options(s: &mut Session, p: &Value) -> Result { sel.text = None; return Ok(json!({"deleted": true})); } - if let Some(v) = p.get("start").and_then(Value::as_f64) { - pt.start = v.max(0.0); + if let Some(v) = p.get("start").and_then(Value::as_f64).filter(|v| v.is_finite()) { + pt.start = v.max(0.0).min(len); + } + match p.get("end") { + Some(Value::Null) => pt.end = None, + Some(v) => { + let e = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("type.pathOptions", "end: a distance or null"))?; + pt.end = (e < len - 0.01).then_some(e.max(0.0)); + } + None => {} + } + // The brackets can't cross: the one being moved stops at the other. + if let Some(e) = pt.end + && pt.start > e + { + if p.get("end").is_some() { + pt.end = Some(pt.start); + } else { + pt.start = e; + } } if let Some(v) = p.get("flip").and_then(Value::as_bool) { pt.flip = v; @@ -104,7 +123,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result { if let Some(a) = align { pt.align = a; } - Ok(json!({"start": pt.start, "flip": pt.flip, "align": pt.align})) + Ok(json!({"start": pt.start, "end": pt.end, "flip": pt.flip, "align": pt.align})) }) } @@ -160,4 +179,93 @@ mod tests { let pdf = designcraft_pdf::export_pdf(&d, &s2.cache, &Default::default()).unwrap(); assert!(pdf.len() > 1000); } + + #[test] + fn the_brackets_set_where_the_text_starts_and_ends() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("line.create", &json!({"a": [100, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("type.onPath", &json!({"id": id, "text": "Sixteen letters!"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let overset = |s: &Session| designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()).overset_at.is_some(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(!overset(&s)); + // The end bracket close to the start: the text no longer fits, and is overset. + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 50, "end": 70})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(50.0), Some(70.0))); + assert!(overset(&s)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Brackets can't cross: the start stops at the end, the end at the start. + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 90})).unwrap(); + assert_eq!(r["start"].as_f64(), Some(70.0)); + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 10, "end": 5})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(10.0), Some(10.0))); + // Past the path, or null: the path's end again; the start stays on the path. + let r = s.execute("type.pathOptions", &json!({"id": id, "end": 1000})).unwrap(); + assert!(r["end"].is_null()); + let r = s.execute("type.pathOptions", &json!({"id": id, "end": 200})).unwrap(); + assert_eq!(r["end"].as_f64(), Some(200.0)); + let r = s.execute("type.pathOptions", &json!({"id": id, "end": null, "start": 5000})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].is_null()), (Some(400.0), true)); + assert!(s.execute("type.pathOptions", &json!({"id": id, "end": "far"})).is_err()); + // Saved with the document. + let d = s.doc().unwrap().doc.clone(); + let back = crate::cmd::file_from(&crate::cmd::file_bytes(&d)).unwrap(); + let path = |d: &designcraft_doc::Document| d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.path.clone(); + assert_eq!(path(&back), path(&d)); + } + + #[test] + fn the_type_tool_clicks_and_selects_along_a_curved_path() { + for flip in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // An arch: up from (100, 400) over (300, 200) and down to (500, 400). + let id = s + .execute("path.create", &json!({"anchors": [{"p": [100, 400], "out": [100, 250]}, {"p": [300, 200], "in": [200, 200], "out": [400, 200]}, {"p": [500, 400], "in": [500, 250]}]})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let r = s.execute("type.onPath", &json!({"id": id, "text": "AAAA BBBB CCCC DDDD EEEE FFFF GGGG", "flip": flip})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + // Where each glyph sits on the path, from the composed line. + let d = s.doc().unwrap().doc.clone(); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + let ft = &cs.frames[0]; + let map = designcraft_compose::PathTextMap::new(it, ft).unwrap(); + let l = &ft.lines[0]; + let glyph = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).unwrap(); + let on_path = |byte: usize| { + let g = glyph(byte); + // A little outside the path, as a click near the letter would be. + map.to_path(designcraft_geom::Point::new(g.x + g.adv * 0.3, l.baseline - 4.0)) + }; + // A click by the first C (byte 10), on the curve: the caret goes there. + let p = on_path(10); + let r = s.execute("text.placeCaret", &json!({"frame": id, "point": [p.x, p.y]})).unwrap(); + assert_eq!(r["pos"], 10, "flip={flip}: {r}"); + // Dragging to the first E (byte 20) selects C to D. + let q = on_path(20); + s.execute("text.extendTo", &json!({"frame": id, "point": [q.x, q.y]})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!(t.range(), 10..20, "flip={flip}"); + // A double-click on a word (its first click places the caret) selects it. + let w = on_path(16); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); + s.execute("text.selectWord", &json!({"frame": id, "point": [w.x, w.y]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 15..19, "flip={flip}"); + // A click on a letter, above the path, hits the frame (the type tool's hit test). + let above = map.to_path(designcraft_geom::Point::new(glyph(10).x, l.baseline - l.ascent * 0.7)); + let sp = s.doc().unwrap().doc.clone(); + assert_eq!( + sp.hit_item(0, sp.parent_xf(&sp.find(designcraft_doc::ItemId(id)).unwrap()) * above, 1.0), + Some(designcraft_doc::ItemId(id)), + "flip={flip}" + ); + // The map runs both ways along the path. + let back = map.to_line(map.to_path(designcraft_geom::Point::new(123.0, l.baseline))); + assert!((back.x - 123.0).abs() < 0.01, "{back:?}"); + } + } } diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 3fc061d7d..f5ee8dfdf 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -5,6 +5,7 @@ use designcraft_compose::ComposedStory; use designcraft_doc::{Content, Document, Intent, Item, SpreadRef}; use designcraft_fonts::FaceRef; +use designcraft_geom::Affine; use serde::Serialize; use serde_json::{Value, json}; @@ -29,10 +30,12 @@ pub fn specs() -> Vec { "{minPpi?: 150} → issues [{severity, kind, message, item?, page?}]", has_doc, run)] } -fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option { +fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option { let SpreadRef::Doc(si) = sr else { return None }; let sp = d.spreads.get(si)?; - Some(d.first_page_of_spread(si) + sp.page_at_x(it.bounds().center().x).unwrap_or(0)) + // Item bounds include its own transform; ancestors bring the frame into spread space. + let bounds = parent.transform_rect_bbox(it.bounds()); + Some(d.first_page_of_spread(si) + sp.page_at_x(bounds.center().x).unwrap_or(0)) } /// Characters a missing-glyph report names before "and N more". @@ -85,7 +88,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { missing_glyphs(&cs, &story.text, 0, &mut missing); if cs.is_overset() { let last = story.frames.last().copied(); - let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it)); + let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, Affine::IDENTITY)); let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count(); out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page }); } @@ -161,48 +164,52 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { page: None, }); } - // Items. + // Carry the ancestor transform in the same order as rendering, retaining pre-order. for (si, sp) in d.spreads.iter().enumerate() { - for top in &sp.items { - top.walk(&mut |it| { - let page = page_of(d, SpreadRef::Doc(si), it); - match &it.content { - Content::Graphic(g) => match d.assets.get(&g.asset) { - None => { - out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }) - } - Some(a) if a.data.is_empty() => out.push(Issue { - severity: "error", - kind: "missingLink", - message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)), - item: Some(it.id.0), - page, - }), - Some(a) => { - // Effective ppi: pixels per inch at the placed size. Placed PDF and - // SVG pages export as vectors; their `pixels` is only the preview's. - let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data); - if let Some((pw, _)) = a.pixels.filter(|_| !vector) { - let placed_w = (g.xf * it.xf).as_coeffs()[0].hypot((g.xf * it.xf).as_coeffs()[1]) * g.size.0; - let ppi = pw as f64 / (placed_w / 72.0).max(1e-6); - if d.settings.intent == Intent::Print && ppi < min_ppi { - out.push(Issue { - severity: "warning", - kind: "lowResolution", - message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name), - item: Some(it.id.0), - page, - }); - } + let mut pending: Vec<_> = sp.items.iter().rev().map(|it| (it.as_ref(), Affine::IDENTITY)).collect(); + while let Some((it, parent)) = pending.pop() { + let xf = parent * it.xf; + pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf))); + let page = page_of(d, SpreadRef::Doc(si), it, parent); + match &it.content { + Content::Graphic(g) => match d.assets.get(&g.asset) { + None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }), + Some(a) if a.data.is_empty() => out.push(Issue { + severity: "error", + kind: "missingLink", + message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)), + item: Some(it.id.0), + page, + }), + Some(a) => { + // Placed PDF and SVG pages export as vectors; `pixels` is only the preview's. + let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data); + if let Some((pw, ph)) = a.pixels.filter(|_| !vector) { + // Measure each image axis in points, not its rotated bounding box or + // cropped frame. Either axis can fall below the minimum resolution. + let [xx, yx, xy, yy, _, _] = (xf * g.xf).as_coeffs(); + let placed_w = xx.hypot(yx) * g.size.0; + let placed_h = xy.hypot(yy) * g.size.1; + let ppi_x = pw as f64 / (placed_w / 72.0).max(1e-6); + let ppi_y = ph as f64 / (placed_h / 72.0).max(1e-6); + let ppi = ppi_x.min(ppi_y); + if d.settings.intent == Intent::Print && ppi < min_ppi { + out.push(Issue { + severity: "warning", + kind: "lowResolution", + message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name), + item: Some(it.id.0), + page, + }); } } - }, - Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => { - out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page }); } - _ => {} + }, + Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => { + out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page }); } - }); + _ => {} + } } } // RGB swatches in use in a print document. diff --git a/crates/engine/src/cmd/qr.rs b/crates/engine/src/cmd/qr.rs index c201de4ee..d68f5e696 100644 --- a/crates/engine/src/cmd/qr.rs +++ b/crates/engine/src/cmd/qr.rs @@ -58,7 +58,7 @@ fn qr_code(s: &mut Session, p: &Value) -> Result { let text = encode(p).map_err(|e| bad(ID, e))?; let color = str_param(p, "color").unwrap_or(designcraft_color::swatch::BLACK).to_string(); let rect = rect_param(p, "rect"); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, ID)?; let st = s.doc()?; let lid = st.active_layer; let sel = st.selection.items.clone(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 3eb241b28..0280612d1 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -105,7 +105,7 @@ pub fn specs() -> Vec { "New Character Style…", [], None, - "{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)", + "{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)", has_doc, create_char ), @@ -181,9 +181,18 @@ pub fn specs() -> Vec { [], None, "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)", - |s| super::has_text(s).or_else(|_| super::has_selection(s)), + has_text_or_selection, apply_color ), + cmd!( + "color.sample", + "Sample Color (Eyedropper)", + [], + None, + "{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects", + has_text_or_selection, + sample_color + ), cmd!( "swatch.addToSwatches", "Add to Swatches", @@ -444,6 +453,7 @@ fn create_para(s: &mut Session, p: &Value) -> Result { chars = full; } } + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; let based_on = str_param(p, "basedOn").map(str::to_string); let next = str_param(p, "nextStyle").map(str::to_string); s.edit(|d, _| { @@ -470,6 +480,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { fn edit_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); s.edit(|d, _| { @@ -620,7 +631,26 @@ fn delete_para(s: &mut Session, p: &Value) -> Result { fn create_char(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); - let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) + && let Some(target) = super::text::format_targets_pub(s).first() + { + let st = s.doc()?; + // Match paragraph fromSelection's first-character/caret policy, but keep paragraph + // attributes inherited instead of freezing the fully resolved character properties. + let format = super::text::effective_char_format(s, target)?; + let mut captured = st.doc.styles.char_style_attrs(&format.style); + captured.merge(&format.over); + // Replay the explicit fields so null clears a captured value rather than looking absent. + if let Some(overrides) = p.get("chars").and_then(Value::as_object) { + for (key, value) in overrides { + captured.set_json(key, value).map_err(|e| bad("style", e))?; + } + } + chars = captured; + } // Based on [None] is based on nothing. let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); s.edit(|d, _| { @@ -641,6 +671,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { return Err(bad("style.character.edit", "[None] can't be edited")); } let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?; // `null` unsets an attribute (the style no longer sets it). let unset: Vec = p .get("chars") @@ -840,7 +871,11 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result { let bytes = designcraft_color::ase::write(&chosen); match str_param(p, "path") { Some(path) => { - std::fs::write(path, &bytes).map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; + #[cfg(not(target_arch = "wasm32"))] + let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes); + #[cfg(target_arch = "wasm32")] + let written = std::fs::write(path, &bytes); + written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes)})), @@ -947,42 +982,18 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result { fn apply_color(s: &mut Session, p: &Value) -> Result { let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?; let stroke = str_param(p, "target") == Some("stroke"); - let text = p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some(); + let named = p.get("ids").is_some() || p.get("id").is_some(); + let text = !named + && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some()); let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) }; + if text && texts.is_empty() { + return Err(bad("object.color", "select the text to colour")); + } // A caret: the colour is for the next typing (once the swatch exists). let caret = matches!(texts.as_slice(), [t] if t.range.is_empty()); let r = s.edit(|d, _| { - // Reuse a swatch holding exactly this colour, else add an unnamed one. - let existing = d - .swatches - .iter() - .find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color)) - .map(|w| w.name.clone()); - let name = match existing { - Some(n) => n, - None => { - let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color)); - d.swatches.push(Swatch { - name: name.clone(), - value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, - locked: false, - named: false, - hidden: true, - }); - name - } - }; - for id in &ids { - if let Some(it) = d.item_mut(*id) { - if stroke { - it.stroke.swatch = name.clone(); - it.stroke.tint = 1.0; - } else { - it.fill.swatch = name.clone(); - it.fill.tint = 1.0; - } - } - } + let name = color_swatch(d, color); + color_items(d, &ids, &name, stroke); if !caret { let a = if stroke { CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() } @@ -1000,6 +1011,119 @@ fn apply_color(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches +/// panel until Add to Swatches). +fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String { + let existing = d + .swatches + .iter() + .find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color)) + .map(|w| w.name.clone()); + if let Some(n) = existing { + return n; + } + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color)); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, + locked: false, + named: false, + hidden: true, + }); + name +} + +fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) { + for id in ids { + if let Some(it) = d.item_mut(*id) { + if stroke { + it.stroke.swatch = name.to_string(); + it.stroke.tint = 1.0; + } else { + it.fill.swatch = name.to_string(); + it.fill.tint = 1.0; + } + } + } +} + +fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { super::has_selection(s) } +} + +/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour, +/// as an unnamed colour, fills the selected text (characters), or the selected objects. +fn sample_color(s: &mut Session, p: &Value) -> Result { + const ID: &str = "color.sample"; + let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; + let sr = super::spread_param(p, "spread")?; + let st = s.doc()?; + let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?; + let hex = color.to_hex(); + let mut params = p.clone(); + params["color"] = json!(hex); + let mut result = apply_color(s, ¶ms)?; + result["color"] = json!(hex); + result["from"] = json!(from.0); + Ok(result) +} + +/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose +/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour); +/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS). +pub(crate) fn image_color_at( + d: &designcraft_doc::Document, + sr: designcraft_doc::SpreadRef, + at: designcraft_geom::Point, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + let sp = d.spread(sr)?; + let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| d.layer_rank(i.layer)); + items + .into_iter() + .rev() + .filter(|it| d.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0)) +} + +fn sample_item( + d: &designcraft_doc::Document, + it: &designcraft_doc::Item, + outer: designcraft_geom::Affine, + at: designcraft_geom::Point, + depth: usize, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + use designcraft_doc::Content; + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + match &it.content { + Content::Group { items } => items.iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)), + Content::Graphic(g) => { + let inner = xf.inverse() * at; + if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) { + return None; + } + let gp = g.xf.inverse() * inner; + let (w, h) = g.size; + // Written so that NaN (a degenerate transform) is outside too. + if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { + return None; + } + let asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?; + let img = designcraft_images::decode_rgba(&asset.data)?; + let (pw, ph) = img.dimensions(); + let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); + let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); + let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0; + (a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b))) + } + _ => None, + } +} + fn add_to_swatches(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let name = match str_param(p, "swatch") { @@ -1011,7 +1135,7 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result { } }; let rename = str_param(p, "name").map(str::to_string); - s.edit(|d, _| { + let result = s.edit(|d, _| { let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; if w.locked { return Err(bad("swatch.addToSwatches", "special swatches are already listed")); @@ -1040,18 +1164,26 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result { } for st in d.stories.values_mut() { let mut copy = (**st).clone(); - if rename_in_story(&mut copy, &name, &new) { + if rename_in_story(&mut copy, &name, &new, 0)? { *st = std::sync::Arc::new(copy); } } Ok(json!({"name": new})) - }) + })?; + if let Some(new) = result.get("name").and_then(Value::as_str) { + super::text::rename_typing_swatch(s, &name, new); + } + Ok(result) } /// Point text colours (character fill, stroke, underline and strikethrough; paragraph shading, /// border and rules; table cells, borders and alternating fills) at a renamed swatch, in a story /// and the texts inside it (table cells, footnotes, endnotes). Returns whether anything changed. -fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str) -> bool { +fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str, depth: usize) -> Result { + // Refuse the whole edit instead of leaving deeper references pointing at a removed name. + if depth > 32 { + return Err(bad("swatch.addToSwatches", "story nesting exceeds 32 levels")); + } fn fix(s: &mut String, from: &str, to: &str) -> bool { let hit = s == from; if hit { @@ -1097,17 +1229,17 @@ fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str) -> boo for k in &mut c.strokes { f(&mut k.color); } - nested |= rename_in_story(&mut c.text, from, to); + nested |= rename_in_story(&mut c.text, from, to, depth + 1)?; } } for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { - nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to); + nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to, depth + 1)?; } let changed = n > 0 || nested; if changed { st.rev = st.rev.wrapping_add(1); } - changed + Ok(changed) } /// Point fills and strokes of an item (and its children) at a renamed swatch. @@ -1129,6 +1261,154 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) { mod color_tests { use super::*; + fn fill_session() -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap(); + session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap(); + session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.create", &json!({"name": "Existing"})).unwrap(); + session.execute("type.char", &json!({"size": 18})).unwrap(); + session.execute("edit.undo", &json!({})).unwrap(); + session + } + + fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) { + let before = session.doc().unwrap().clone(); + let journal = session.journal.clone(); + let fill = params + .pointer("/chars/fill") + .or_else(|| params.pointer("/attrs/fill")) + .or_else(|| params.get("fill")) + .and_then(Value::as_str) + .unwrap_or("Missing Ink"); + + let result = session.execute(command, ¶ms); + + match result { + Err(crate::EngineError::BadParams { cmd, msg }) => { + assert_eq!(cmd, command); + assert!(msg.contains(fill), "{msg}"); + assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}"); + } + other => panic!("expected BadParams for {command}, got {other:?}"), + } + let after = session.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); + assert_eq!(before.selection, after.selection); + assert_eq!(before.revision, after.revision); + assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history)); + assert_eq!(journal, session.journal); + } + + #[test] + fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() { + for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] { + for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "type.char", params); + } + } + } + + #[test] + fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() { + for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap(); + assert_fill_rejected(&mut session, command, json!({"name": "Existing", "rename": "Renamed", "chars": {"fill": fill, "size": 24}})); + } + } + } + + #[test] + fn char_fill_rejects_inherited_unknown_name_when_creating_from_selection() { + let mut session = fill_session(); + session + .edit(|document, selection| { + let selected = selection.text.unwrap(); + document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); + Ok(()) + }) + .unwrap(); + + assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true})); + } + + #[test] + fn char_fill_accepts_valid_override_when_selection_has_unknown_name() { + let mut session = fill_session(); + session + .edit(|document, selection| { + let selected = selection.text.unwrap(); + document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); + Ok(()) + }) + .unwrap(); + + session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); + + assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]")); + } + + #[test] + fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() { + for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}})); + } + } + } + + #[test] + fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() { + for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] { + let mut session = fill_session(); + if !fill.starts_with('[') { + let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap(); + assert_eq!(created["name"], fill); + } + for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] { + session.execute("type.char", ¶ms).unwrap(); + assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); + } + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap(); + let document = &session.doc().unwrap().doc; + for name in ["Colored", "Existing"] { + let attrs = if kind == "paragraph" { + &document.styles.para(name).unwrap().chars + } else { + &document.styles.char_style(name).unwrap().chars + }; + assert_eq!(attrs.fill.as_deref(), Some(fill)); + } + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1); + let selected = session.doc().unwrap().selection.text.unwrap(); + let story = session.doc().unwrap().doc.story(selected.story).unwrap(); + assert_eq!(story.text, "Bye"); + assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill)); + } + } + + #[test] + fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() { + for chars in [json!({"size": 18}), json!({"fill": null})] { + let mut session = fill_session(); + session.execute("type.char", &json!({"attrs": chars})).unwrap(); + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap(); + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1); + } + } + #[test] fn percent_components_at_most_one_stay_percent() { use designcraft_color::Color; @@ -1455,3 +1735,116 @@ mod cjk_tests { assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err()); } } + +#[cfg(test)] +mod sample_color_tests { + use designcraft_color::{Color, SwatchValue}; + use designcraft_doc::ItemId; + use serde_json::json; + + use crate::Session; + + /// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt. + fn session_with_image() -> (Session, u64) { + let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let b64 = super::super::file::base64_encode(&png); + let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap(); + (s, r["id"].as_u64().unwrap()) + } + + fn swatch_color(s: &Session, name: &str) -> Color { + match &s.doc().unwrap().doc.swatch(name).unwrap().value { + SwatchValue::Color { color, .. } => *color, + other => panic!("{other:?}"), + } + } + + #[test] + fn the_eyedropper_colours_selected_text_with_the_image_under_it() { + let (mut s, image) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap(); + assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let name = r["swatch"].as_str().unwrap().to_string(); + assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255)); + assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour"); + let st = s.doc().unwrap().doc.story(story).unwrap().clone(); + assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str())); + assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour"); + // The frame itself is not coloured. + assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none()); + } + + #[test] + fn the_eyedropper_fills_selected_objects_and_samples_locked_images() { + let (mut s, _) = session_with_image(); + let lid = s.doc().unwrap().doc.layers[0].id.0; + s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap(); + s.execute("layer.new", &json!({"name": "TEXT"})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + assert_eq!(r["color"], "#ff0000", "{r}"); + let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone(); + assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0)); + // Sampling the same colour again reuses its swatch. + let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap(); + assert_eq!(again["swatch"], r["swatch"]); + } + + #[test] + fn nothing_to_sample_is_refused_without_an_edit() { + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); + let sampled = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], sampled["swatch"]); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing"); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); + assert!(none.contains("no placed image"), "{none}"); + // Just past the image's right edge. + assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + + #[test] + fn sampling_and_explicit_colour_use_the_same_text_application_path() { + for command in ["color.sample", "object.color"] { + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap(); + let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute(command, &json!({"at": [100, 100], "color": "#ff0000"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}"); + s.execute("text.insert", &json!({"text": "Hi"})).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}"); + // Explicit ids override text targeting, including with a text selection active. + let fid = ItemId(f["id"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "id": fid.0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap()); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}"); + let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "target": "stroke"})).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); + } + } +} + +#[cfg(test)] +#[path = "text_color_tests.rs"] +mod text_color_tests; diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c2313170a..ad306be8e 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -178,7 +178,7 @@ fn table_specs() -> Vec { "Split Cell Horizontally", ["Table"], None, - "{} — the target cell becomes two, one above the other", + "{} — each target cell becomes two, one above the other", in_table, |s, p| split(s, p, true) ), @@ -187,7 +187,7 @@ fn table_specs() -> Vec { "Split Cell Vertically", ["Table"], None, - "{} — the target cell becomes two, side by side", + "{} — each target cell becomes two, side by side", in_table, |s, p| split(s, p, false) ), @@ -589,7 +589,7 @@ fn merge(s: &mut Session, p: &Value) -> Result { fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result { let g = target(s, p, "table.split")?; edit_table(s, &g, "table.split", |t| { - t.split_cell(g.range.r0, g.range.c0, horizontal); + t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?; Ok(json!({"rows": t.nrows(), "cols": t.ncols()})) }) } @@ -645,7 +645,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))), None => None, }; - let text = str_param(p, "text").map(str::to_string); + let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = p.get("stroke").cloned(); edit_table(s, &g, "table.setCell", |t| { let owners = t.owners(); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index de48e4884..e928ce472 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -34,15 +34,24 @@ pub fn specs() -> Vec { has_doc, |s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word")) ), + cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point", has_doc, |s, p| { + select_around(s, p, "text.selectSentence", sentence_bounds) + }), + cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point, without its paragraph return", has_doc, |s, p| { + select_around(s, p, "text.selectParagraph", para_bounds) + }), + cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included", has_doc, |s, p| { + select_around(s, p, "text.selectStory", |t, _| (0, t.len())) + }), cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0)); - s.typing_format = None; let st = s.doc_mut()?; let text = &st.doc.story(sid).ok_or_else(|| bad("text.select", "no such story"))?.text; let a = floor_char_boundary(text, p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize); let f = floor_char_boundary(text, p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a)); st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None, cell: None }); st.revision += 1; + s.typing_format = None; ok() }), cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert), @@ -80,7 +89,7 @@ pub fn specs() -> Vec { ), cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?); - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { d.set_story_text(sid, &text)?; if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { @@ -101,7 +110,7 @@ pub fn specs() -> Vec { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?); let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize; let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a); - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len()))); @@ -174,6 +183,15 @@ pub fn specs() -> Vec { has_text_or_frames, |s, p| link_style(s, p, false) ), + cmd!( + "type.swapFillStroke", + "Swap Text Fill and Stroke", + [], + None, + "{} — swap each selected character's resolved fill/stroke and tints; at a caret, swap the next typing's colors", + has_text_or_frames, + swap_fill_stroke + ), cmd!("type.underline", "Underline", [], Some("Cmd+Shift+U"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"underline": true}))), cmd!("type.allCaps", "All Caps", [], Some("Cmd+Shift+K"), "{}", has_text_or_frames, |s, _| format_chars( s, @@ -273,7 +291,7 @@ pub fn specs() -> Vec { has_text_or_frames, break_link ), - cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs), + cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs), ] } @@ -310,6 +328,8 @@ fn hit_byte_with( let inner = xf.inverse() * pt; let cs = s.cache.get(&st.doc, sid, None); let fi = cs.frames.iter().position(|f| f.frame == frame)?; + // Map path-text hits along the curve before choosing the character unit. + let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner)); if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) { return Some((sid, b, Some(CellAddr { table, row, col }))); } @@ -483,8 +503,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { let tracking = s.doc()?.doc.settings.track_changes; let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone()); let typing = typing_format(s).cloned(); - s.typing_format = None; - s.edit(|d, sel| { + let result = s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?; let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); @@ -562,7 +581,11 @@ fn insert(s: &mut Session, p: &Value) -> Result { let pos = r.start + text.len() - trail; sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); Ok(json!({"pos": pos})) - }) + }); + if result.is_ok() { + s.typing_format = None; + } + result } fn smart_quotes(s: &Session, text: &str) -> String { @@ -739,6 +762,62 @@ pub fn prev_word(s: &str, i: usize) -> usize { } 0 } +/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret +/// placement, a press inside the current selection is no start of a text drag here: it is the +/// third or later click of a row, which lands in the word or sentence just selected. +fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) -> (usize, usize)) -> Result { + let frame = id_param(p, "frame").ok_or_else(|| bad(id, "missing frame"))?; + let pt = point_param(p, "point").unwrap_or(Point::ZERO); + let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad(id, "not a text frame"))?; + let st = s.doc_mut()?; + let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default(); + let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b)); + st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); + st.revision += 1; + Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})) +} + +/// The paragraph around byte `i`, without its `\n`. +fn para_bounds(s: &str, i: usize) -> (usize, usize) { + let i = i.min(s.len()); + let a = s.get(..i).and_then(|t| t.rfind('\n')).map_or(0, |k| k + 1); + let b = s.get(i..).and_then(|t| t.find('\n')).map_or(s.len(), |k| i + k); + (a, b) +} + +/// The sentence around byte `i`, within its paragraph: it ends after `.`, `!`, `?` or `…` (and +/// any closing quotes or brackets) followed by a space or the paragraph end, or after a CJK full +/// stop, and takes the spaces after it, as deleting a sentence should leave none behind. +fn sentence_bounds(s: &str, i: usize) -> (usize, usize) { + let (pa, pb) = para_bounds(s, i); + let para = s.get(pa..pb).unwrap_or(""); + let mut start = 0; + let mut it = para.char_indices().peekable(); + while let Some((_, c)) = it.next() { + let cjk = matches!(c, '。' | '!' | '?'); + if !cjk && !matches!(c, '.' | '!' | '?' | '…') { + continue; + } + while it.peek().is_some_and(|&(_, c)| { + matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?' | '"' | '\'' | '”' | '’' | '»' | '›' | ')' | ']' | '」' | '』' | ')') + }) { + it.next(); + } + if !cjk && it.peek().is_some_and(|&(_, c)| !c.is_whitespace()) { + continue; + } + while it.peek().is_some_and(|&(_, c)| c.is_whitespace()) { + it.next(); + } + let end = it.peek().map_or(para.len(), |&(k, _)| k); + if pa + end > i || end == para.len() { + return (pa + start, pa + end); + } + start = end; + } + (pa + start, pb) +} + /// Type ▸ Story Direction. fn story_direction(s: &mut Session, p: &Value) -> Result { let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; @@ -944,6 +1023,18 @@ fn warichu_cmd(s: &mut Session, p: &Value) -> Result { format_chars(s, &Value::Object(body)) } +pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> { + if let Some(fill) = &attrs.fill + && document.swatch(fill).is_none() + { + return Err(bad( + command_id, + format!("unknown character fill `{fill}`; create a swatch with `swatch.create` and use its returned name as `fill`"), + )); + } + Ok(()) +} + pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { let mut a = CharAttrs::default(); if let Some(o) = attrs.as_object() { @@ -951,6 +1042,7 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { a.set_json(k, v).map_err(|e| bad("type.char", e))?; } } + validate_char_fill(&s.doc()?.doc, &a, "type.char")?; let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); let targets = format_targets(s); // A caret in text: the format is for the next typing. @@ -1100,6 +1192,17 @@ fn open_type(s: &mut Session, p: &Value) -> Result { Ok(json!({"features": list, "figures": otf::figures(&list)})) } +/// How much of the selected text `type.selectionAttrs` reports. +const SELECTED_TEXT_CHARS: usize = 60; + +fn selected_text_preview(chars: impl Iterator) -> String { + chars + .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) + .skip_while(|c| c.is_whitespace()) + .take(SELECTED_TEXT_CHARS) + .collect() +} + fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let st = s.doc()?; let targets = format_targets(s); @@ -1108,13 +1211,115 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let pi = story.para_at(r.start); let pf = &story.paras[pi]; let (pp, base) = st.doc.styles.resolve_para(pf); - let mut cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }.clone(); - if let Some(f) = typing_format(s) { - apply_typing_format(&mut cf, f); - } + let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?; let cp = st.doc.styles.resolve_char(&base, &cf); + let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, - "paraOverrides": pf.para.count(), "charOverrides": cf.over.count()})) + "paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()})) +} + +/// The first target character, with any pending caret overrides. Keep character-style +/// inheritance separate from paragraph defaults for style creation from the selection. +pub(crate) fn effective_char_format(s: &Session, target: &Target) -> Result { + let story = s.doc()?.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let mut format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone(); + if let Some(pending) = typing_format(s) + && target.story == pending.at.story + && target.cell == pending.at.cell + && target.range == pending.at.range() + { + apply_typing_format(&mut format, pending); + } + Ok(format) +} + +/// A swatch promoted to a named color keeps any not-yet-inserted text pointing at it. +pub(crate) fn rename_typing_swatch(s: &mut Session, from: &str, to: &str) { + let Some(uid) = s.active().map(|st| st.uid) else { return }; + if let Some(pending) = &mut s.typing_format + && pending.doc == uid + { + for name in [&mut pending.attrs.fill, &mut pending.attrs.stroke, &mut pending.attrs.underline_color, &mut pending.attrs.strikethrough_color] + .into_iter() + .flatten() + { + if name == from { + *name = to.to_string(); + } + } + } +} + +fn swapped_colors(cp: &designcraft_doc::CharProps) -> CharAttrs { + CharAttrs { + fill: Some(cp.stroke.clone()), + fill_tint: Some(cp.stroke_tint), + stroke: Some(cp.fill.clone()), + stroke_tint: Some(cp.fill_tint), + ..Default::default() + } +} + +/// Resolve each paragraph/run before editing: a mixed selection must not take the first run's colors. +fn swap_fill_stroke(s: &mut Session, _p: &Value) -> Result { + const ID: &str = "type.swapFillStroke"; + let targets = format_targets(s); + if let [target] = targets.as_slice() + && target.range.is_empty() + && s.doc()?.selection.text.is_some() + { + let st = s.doc()?; + let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = st.doc.styles.resolve_para(pf); + let cp = st.doc.styles.resolve_char(&base, &effective_char_format(s, target)?); + return format_chars(s, &json!({"fill": cp.stroke, "fillTint": cp.stroke_tint, "stroke": cp.fill, "strokeTint": cp.fill_tint})); + } + let mut edits = Vec::new(); + let d = &s.doc()?.doc; + for target in targets { + let story = d.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + if story.is_empty() { + let pf = story.paras.first().ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = d.styles.resolve_para(pf); + let attrs = swapped_colors(&d.styles.resolve_char(&base, story.char_format_at(0))); + validate_char_fill(d, &attrs, ID)?; + edits.push((target, attrs)); + continue; + } + let mut runs = story.runs().peekable(); + for (pi, para) in story.para_ranges().into_iter().enumerate() { + let start = para.start.max(target.range.start); + let end = para.end.saturating_add(1).min(story.len()).min(target.range.end); + if start >= end { + continue; + } + let pf = story.paras.get(pi).ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = d.styles.resolve_para(pf); + while let Some((range, format)) = runs.peek() { + if range.end <= start { + runs.next(); + continue; + } + if range.start >= end { + break; + } + let attrs = swapped_colors(&d.styles.resolve_char(&base, format)); + validate_char_fill(d, &attrs, ID)?; + edits.push((Target { range: start.max(range.start)..end.min(range.end), ..target.clone() }, attrs)); + if range.end > end { + break; + } + runs.next(); + } + } + } + s.edit(|d, _| { + for (target, attrs) in &edits { + apply_char_attrs(d, std::slice::from_ref(target), attrs, &[]); + } + ok() + }) } fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) { @@ -1348,6 +1553,37 @@ mod open_type_tests { s.execute("text.move", &json!({"dir": "right"})).unwrap(); } + #[test] + fn selected_text_preview_stops_at_its_character_budget() { + let read = std::cell::Cell::new(0usize); + let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK]; + let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1)); + let preview = super::selected_text_preview(chars); + assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS)); + assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned"); + assert_eq!(super::selected_text_preview("A\nB".chars()), "A B"); + } + + #[test] + fn selection_attrs_report_the_selected_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute( + "frame.create", + &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue +What's new in typography this season, and why it matters to every designer"}), + ) + .unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap(); + let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string(); + assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing"); + } + #[test] fn open_type_toggles_reach_the_text() { let mut s = Session::new(); @@ -2286,3 +2522,44 @@ mod story_query_contract_tests { assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], ""); } } + +#[cfg(test)] +mod sentence_selection_tests { + use super::{para_bounds, sentence_bounds}; + + fn sentence(s: &str, at: &str) -> String { + let i = s.find(at).unwrap(); + let (a, b) = sentence_bounds(s, i); + s[a..b].to_string() + } + + #[test] + fn sentences_end_at_a_stop_before_a_space_or_the_paragraph_end() { + let s = "It costs 3.5 euros. \u{201C}Really?\u{201D} (Twice!) Yes\u{2026} fine.\nNext one. Last"; + assert_eq!(sentence(s, "costs"), "It costs 3.5 euros. "); + assert_eq!(sentence(s, "Really"), "\u{201C}Really?\u{201D} "); + assert_eq!(sentence(s, "Twice"), "(Twice!) "); + assert_eq!(sentence(s, "Yes"), "Yes\u{2026} "); + assert_eq!(sentence(s, "fine"), "fine."); + assert_eq!(sentence(s, "one"), "Next one. "); + assert_eq!(sentence(s, "Last"), "Last"); + // The caret at the very end of a paragraph is in its last sentence. + let end = s.find('\n').unwrap(); + assert_eq!(sentence_bounds(s, end), (s.find("fine").unwrap(), end)); + } + + #[test] + fn cjk_full_stops_end_sentences_without_a_space() { + let s = "これは文です。次の文!最後"; + assert_eq!(sentence(s, "次"), "次の文!"); + assert_eq!(sentence(s, "最後"), "最後"); + } + + #[test] + fn paragraphs_stop_at_returns() { + let s = "One.\nTwo two.\n"; + assert_eq!(para_bounds(s, 6), (5, 13)); + assert_eq!(para_bounds(s, 0), (0, 4)); + assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); + } +} diff --git a/crates/engine/src/cmd/text_color_tests.rs b/crates/engine/src/cmd/text_color_tests.rs new file mode 100644 index 000000000..5f2c35765 --- /dev/null +++ b/crates/engine/src/cmd/text_color_tests.rs @@ -0,0 +1,181 @@ +//! Cross-command caret color and character-style regressions. +use crate::Session; +use designcraft_doc::StoryId; +use serde_json::{Value, json}; + +fn setup(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [36,36,300,200], "content":"text", "text":text})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story":sid,"anchor":text.len()})).unwrap(); + (s, sid) +} +fn attrs(s: &mut Session) -> Value { + s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"].clone() +} +#[test] +fn integration_color_character_style_captures_pending_and_null() { + let (mut s, sid) = setup("hello"); + s.execute("type.char", &json!({"size":30,"tracking":40,"fill":"[Paper]"})).unwrap(); + s.execute("style.character.create", &json!({"name":"Pending","fromSelection":true,"chars":{"tracking":null}})).unwrap(); + let a = &s.doc().unwrap().doc.styles.char_style("Pending").unwrap().chars; + assert_eq!(a.size, Some(30.0)); + assert_eq!(a.fill.as_deref(), Some("[Paper]")); + assert_eq!(a.tracking, None); + assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "hello"); +} +#[test] +fn integration_color_pending_follows_promoted_swatch() { + for target in ["fill", "stroke"] { + let (mut s, sid) = setup("hello"); + let old = s.execute("object.color", &json!({"color":"#123456","target":target})).unwrap()["swatch"].clone(); + s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap(); + assert_eq!(attrs(&mut s)[target], "Brand"); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap(); + let f = &st.format_after(5).over; + assert_eq!(if target == "fill" { f.fill.as_deref() } else { f.stroke.as_deref() }, Some("Brand")); + } +} +#[test] +fn integration_color_undo_discards_missing_pending_swatch() { + let (mut s, sid) = setup("hello"); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.swatch(&old).is_none()); + assert_ne!(attrs(&mut s)["fill"], old); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + assert_ne!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(5).over.fill.as_deref(), Some(old.as_str())); +} +#[test] +fn integration_color_empty_story_uses_one_undo_step() { + let (mut s, sid) = setup(""); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + s.execute("object.color", &json!({"color":"#123456"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + let colored = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), colored); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(0).over.fill.is_some()); +} + +#[test] +fn integration_color_swap_resolves_each_paragraph_and_keeps_unselected_text() { + let (mut s, sid) = setup("ab\ncd"); + for (name, fill, stroke, ft, st, start, end) in + [("First", "[Paper]", "[Black]", 0.2, 0.8, 0, 2), ("Second", "[Black]", "[Paper]", 0.4, 0.6, 3, 5)] + { + s.execute("style.paragraph.create", &json!({"name":name,"chars":{"fill":fill,"stroke":stroke,"fillTint":ft,"strokeTint":st}})).unwrap(); + s.execute("text.select", &json!({"story":sid,"anchor":start,"focus":end})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name":name})).unwrap(); + } + s.execute("text.select", &json!({"story":sid,"anchor":1,"focus":4})).unwrap(); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + s.execute("type.swapFillStroke", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + for (pos, fill, stroke, ft, st) in [ + (0, "[Paper]", "[Black]", 0.2, 0.8), + (1, "[Black]", "[Paper]", 0.8, 0.2), + (2, "[Black]", "[Paper]", 0.8, 0.2), + (3, "[Paper]", "[Black]", 0.6, 0.4), + (4, "[Black]", "[Paper]", 0.4, 0.6), + ] { + s.execute("text.select", &json!({"story":sid,"anchor":pos,"focus":pos+1})).unwrap(); + let a = attrs(&mut s); + assert_eq!(a["fill"], fill); + assert_eq!(a["stroke"], stroke); + assert!((a["fillTint"].as_f64().unwrap() - ft).abs() < 1e-6); + assert!((a["strokeTint"].as_f64().unwrap() - st).abs() < 1e-6); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); +} + +#[test] +fn integration_color_swap_caret_applies_only_to_next_typing() { + let (mut s, sid) = setup("hello"); + s.execute("type.char", &json!({"fill":"[Paper]","stroke":"[Black]","fillTint":0.2,"strokeTint":0.8})).unwrap(); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + s.execute("type.swapFillStroke", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); + let a = attrs(&mut s); + assert_eq!(a["fill"], "[Black]"); + assert_eq!(a["stroke"], "[Paper]"); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap(); + assert_eq!(st.text, "helloX"); + assert_eq!(st.format_after(0).over.fill, None); + assert_eq!(st.format_after(5).over.stroke.as_deref(), Some("[Paper]")); + assert_eq!(st.format_after(5).over.fill_tint, Some(0.8)); +} + +#[test] +fn integration_color_swap_selected_empty_cells_keeps_each_format() { + let (mut s, sid) = setup("hello"); + let result = s.execute("table.insert", &json!({"rows":1,"cols":2})).unwrap(); + let tid = result["table"].as_u64().unwrap(); + s.edit(|d, _| { + let table = std::sync::Arc::make_mut(d.story_mut(StoryId(sid)).unwrap().tables.get_mut(&tid).unwrap()); + for (c, fill, stroke) in [(0, "[Paper]", "[Black]"), (1, "[Black]", "[Paper]")] { + let attrs = &mut table.cell_mut(0, c).unwrap().text.chars[0].format.over; + attrs.fill = Some(fill.into()); + attrs.stroke = Some(stroke.into()); + } + Ok(()) + }) + .unwrap(); + s.execute("table.select", &json!({"story":sid,"table":tid,"what":"table"})).unwrap(); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + s.execute("type.swapFillStroke", &json!({})).unwrap(); + let table = &s.doc().unwrap().doc.story(StoryId(sid)).unwrap().tables[&tid]; + assert_eq!(table.cell(0, 0).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Black]")); + assert_eq!(table.cell(0, 1).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Paper]")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); +} + +#[test] +fn integration_color_failed_promotion_preserves_pending_and_history() { + let (mut s, _) = setup("hello"); + s.execute("swatch.create", &json!({"name":"Brand","color":"#654321"})).unwrap(); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone(); + let before = s.doc().unwrap().clone(); + let pending = attrs(&mut s); + assert!(s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).is_err()); + assert_eq!(attrs(&mut s), pending); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len()); +} + +#[test] +fn integration_color_promote_undo_redo_never_reuses_a_missing_pending_name() { + let (mut s, _) = setup("hello"); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone(); + s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string(); + assert!(s.doc().unwrap().doc.swatch(&fill).is_some()); + s.execute("edit.redo", &json!({})).unwrap(); + let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string(); + assert!(s.doc().unwrap().doc.swatch(&fill).is_some()); +} + +#[test] +fn integration_color_swap_validates_new_fill_before_any_edit() { + let (mut s, sid) = setup("hello"); + s.execute("text.select", &json!({"story":sid,"anchor":0,"focus":5})).unwrap(); + s.execute("type.char", &json!({"fill":"[Paper]","stroke":"Missing Ink"})).unwrap(); + let before = s.doc().unwrap().clone(); + let journal = s.journal.clone(); + let error = s.execute("type.swapFillStroke", &json!({})).unwrap_err().to_string(); + assert!(error.contains("Missing Ink"), "{error}"); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len()); + assert_eq!(s.journal, journal); +} diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs index 8cedac277..bdc2ffafd 100644 --- a/crates/engine/src/cmd/thread.rs +++ b/crates/engine/src/cmd/thread.rs @@ -52,9 +52,18 @@ fn frame_param(s: &Session, p: &Value, cmd: &str) -> Result { if !st.doc.item(id).is_some_and(|it| it.is_text_frame()) { return Err(bad(cmd, format!("{id} is not a text frame"))); } + check_editable(&st.doc, id, cmd)?; Ok(id) } +fn check_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> { + let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + if it.locked || d.layer(it.layer).is_some_and(|l| l.locked) { + return Err(bad(cmd, format!("{id} is locked or on a locked layer"))); + } + Ok(()) +} + /// A rect param made safe for a frame: finite, ordered, at least half a point each way. fn frame_rect(p: &Value) -> Result> { let Some(r) = rect_param(p, "rect") else { return Ok(None) }; @@ -68,7 +77,7 @@ fn frame_rect(p: &Value) -> Result> { fn thread(s: &mut Session, p: &Value) -> Result { let (from, to, rect) = (id_param(p, "from"), id_param(p, "to"), frame_rect(p)?); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "spread")?; let autoflow = bool_or(p, "autoflow", false); let layer = s.doc()?.active_layer; s.edit(|d, sel| { @@ -114,6 +123,10 @@ fn unthread(s: &mut Session, p: &Value) -> Result { "before" => d0.prev_frame(frame).ok_or_else(|| bad(CMD, format!("no frame comes before {frame} in its thread")))?, other => return Err(bad(CMD, format!("unknown side `{other}` (after, before)"))), }; + check_editable(d0, at, CMD)?; + if let Some(next) = d0.next_frame(at) { + check_editable(d0, next, CMD)?; + } s.edit(|d, _| { d.unthread_after(at)?; let sid = d.item(at).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad(CMD, "no story"))?; @@ -123,6 +136,10 @@ fn unthread(s: &mut Session, p: &Value) -> Result { fn remove_from_thread(s: &mut Session, p: &Value) -> Result { let frame = frame_param(s, p, "text.removeFromThread")?; + let d = &s.doc()?.doc; + for neighbour in [d.prev_frame(frame), d.next_frame(frame)].into_iter().flatten() { + check_editable(d, neighbour, "text.removeFromThread")?; + } s.edit(|d, _| { d.remove_from_thread(frame)?; Ok(json!({})) diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index bbcfaf0a1..e7772d4ea 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -129,6 +129,63 @@ fn define(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + /// A parent-page page-number marker follows Numbering & Section Options: changing a section's + /// start number renames its pages without changing their index or the number of sections, and + /// the marker composed for a page shows the new name. + #[test] + fn parent_page_number_follows_section_renumbering() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, marker); + let shown = |s: &Session, page: usize| -> usize { + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(&s, 2), 1, "page 3"); + s.execute("layout.section", &json!({"page": 1, "startNumber": 100})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_name(2), "102"); + assert_eq!(shown(&s, 2), 3, "page 102 after renumbering"); + } + + /// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running + /// header is composed for the page it is shown on, not the first page with that name. + #[test] + fn running_header_on_parent_page_with_repeated_page_numbers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); + let names: Vec = (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect(); + assert_eq!(names, ["1", "2", "1", "2"]); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + // The only heading is on the third page (the second section's page 1). + let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": "Epsilon"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let shown = |page: usize| -> usize { + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(0), 0, "no heading on or before the first page"); + assert_eq!(shown(2), "Epsilon".len(), "the third page (named 1) shows its own heading"); + assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it"); + } + #[test] fn running_header_on_parent_page_follows_headings() { let mut s = Session::new(); @@ -158,7 +215,7 @@ mod tests { assert!(matches!(d.find(fid).unwrap().spread, designcraft_doc::SpreadRef::Parent(0))); // Visible glyphs of the header composed for each page: "Alpha · 3" vs "Epsilon · 3". let shown = |page: usize| -> usize { - let cs = s.cache.get(&d, sid, Some(&d.page_name(page))); + let cs = s.cache.get(&d, sid, Some(page)); cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() }; let (p1, p2, p3) = (shown(0), shown(1), shown(2)); diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 32252a9df..6dab205fa 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -305,7 +305,7 @@ pub fn specs() -> Vec { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"text": text, "bytes": text.len()})), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f24750c64..900bd8863 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -7,6 +7,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(not(target_arch = "wasm32"))] +pub mod atomic; pub mod cmd; pub mod dtd; pub mod guard; @@ -91,6 +93,9 @@ pub struct DocState { /// Editing parent spreads (Pages panel double-click on a parent). pub editing_parents: bool, pub uid: u64, + /// Recovery entries this state actually opened or published, for repeated-open and cleanup. + /// Bounded, deduplicated checkpoint addresses; never inferred from uid alone. + pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>, /// Unfilled document while data-merge preview is showing a record. Not saved. pub preview_stash: Option>, /// 1-based record currently previewed. @@ -129,6 +134,7 @@ impl DocState { interaction: None, editing_parents: false, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + recovery_entries: Vec::new(), preview_stash: None, preview_record: None, fonts: None, @@ -177,6 +183,8 @@ pub struct Prefs { pub rich_black_output: bool, /// Font menu favourites (family names). pub favorite_fonts: Vec, + /// Fonts picked in the font menus, most recent first (shown at the menus' top). + pub recent_fonts: Vec, /// Preferences › Type › Show Font Names in English: the font menus show CJK families by their /// English names instead of their native ones. Documents store the English name either way. pub show_font_names_in_english: bool, @@ -233,6 +241,7 @@ impl Default for Prefs { highlight_substituted_fonts: true, rich_black_output: false, favorite_fonts: Vec::new(), + recent_fonts: Vec::new(), show_font_names_in_english: false, smart_text_reflow: true, autocorrect: false, @@ -377,8 +386,9 @@ impl Session { self.stop_data_preview(); } #[cfg(not(target_arch = "wasm32"))] - if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) { - recovery::discard(dir, d.uid); + { + let recovery_dir = self.recovery_dir.clone(); + recovery::discard_document(self, i, recovery_dir.as_deref()); } if i < self.docs.len() { self.docs.remove(i); diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index a038c2d27..33b9a2a33 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -6,6 +6,11 @@ //! A recovery entry is `.designcraft` (the document) plus `.json` (`{"path", "title", //! "saved"}`: the original file, the title, and when it was written). //! +//! Document uids start at 1 in every process, so each running app keeps its entries in a folder +//! of its own in the recovery folder (`run_dir`) and holds the lock in it for as long as it runs. +//! An app that starts moves into its own folder what apps that have ended left in theirs, and +//! leaves the folder of one still running alone. +//! //! Each file is replaced atomically, but the pair is not a transaction. The document is published //! and made durable before its metadata so a crash can leave metadata missing or stale, never a //! listed metadata record without a complete primary document. Listing and opening recovery data @@ -31,6 +36,295 @@ pub fn default_dir() -> Option { std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("designcraft/recovery")) } +/// This process's folder in a recovery folder, with the lock that says it is still running. +#[cfg(not(target_arch = "wasm32"))] +struct Run { + root: PathBuf, + dir: PathBuf, + lock: std::fs::File, + #[cfg(unix)] + directory: std::fs::File, +} + +#[cfg(not(target_arch = "wasm32"))] +static RUNS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// The lock in a run's folder. +#[cfg(not(target_arch = "wasm32"))] +const LOCK: &str = "lock"; + +/// This run's own folder in the recovery folder `root`, made on the first call. Document uids +/// start at 1 in every process, so two running apps writing to one folder wrote over each +/// other's entries. The folder's lock is held until the process ends: an app that starts leaves +/// the folder of one that is still running alone. +#[cfg(not(target_arch = "wasm32"))] +pub fn run_dir(root: &Path) -> Result { + let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(run) = runs.iter().find(|r| r.root == root) { + return Ok(run.dir.clone()); + } + secure_recovery_dir(root)?; + for n in 0..64 { + let dir = root.join(format!("run-{}-{n}", std::process::id())); + // Only a folder this run makes is its own. One that is there is another app's, or was + // left by a run that ended under this process id. + #[cfg(unix)] + let made = { + use std::os::unix::fs::DirBuilderExt; + + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700).create(&dir) + }; + #[cfg(not(unix))] + let made = std::fs::create_dir(&dir); + match made { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + let locked = validate_recovery_dir(&dir).and_then(|()| { + let lock = new_lock(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + match lock.try_lock() { + Ok(()) => { + validate_lock(&dir, &lock)?; + Ok(Some(lock)) + } + Err(std::fs::TryLockError::WouldBlock) => Ok(None), + Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + }); + match locked { + // An app that is starting can take a folder this new for one a run left, and remove + // it before the lock is held: then the lock is gone, or that app holds it. + Ok(Some(lock)) => { + #[cfg(unix)] + let directory = std::fs::File::open(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + runs.push(Run { + root: root.to_path_buf(), + dir: dir.clone(), + lock, + #[cfg(unix)] + directory, + }); + return Ok(dir); + } + Ok(_) => {} + Err(e) => { + // Never unlink a lock that another starting run may now own. + let _ = std::fs::remove_dir(&dir); + return Err(e); + } + } + } + Err(EngineError::Other(format!("{}: no free folder for this run", root.display()))) +} + +#[cfg(not(target_arch = "wasm32"))] +fn new_lock(dir: &Path) -> std::io::Result { + let mut new = std::fs::OpenOptions::new(); + new.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + new.mode(0o600); + } + let mut file = new.open(dir.join(LOCK))?; + // A marker distinguishes the held handle from a replacement at the same pathname on + // platforms without portable file-identity metadata. Empty older-version locks remain valid. + static NEXT_LOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(std::io::Error::other)?.as_nanos(); + let marker = format!("{}:{}:{now}", std::process::id(), NEXT_LOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + file.write_all(marker.as_bytes())?; + file.sync_all()?; + Ok(file) +} + +/// Check the current lock name without accepting a symlink or a lock held through an unlinked +/// handle. An advisory lock on an old handle does not protect a replacement at the same name. +#[cfg(not(target_arch = "wasm32"))] +fn validate_lock(dir: &Path, lock: &std::fs::File) -> Result<()> { + let path = dir.join(LOCK); + let fail = || EngineError::Other(format!("{}: this run no longer owns its recovery lock", path.display())); + let before = std::fs::symlink_metadata(&path).map_err(|_| fail())?; + if is_link_like(&before) || !before.file_type().is_file() { + return Err(fail()); + } + let named = std::fs::File::open(&path).map_err(|_| fail())?; + let held = lock.metadata().map_err(|_| fail())?; + let current = named.metadata().map_err(|_| fail())?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if held.dev() != before.dev() || held.ino() != before.ino() || held.dev() != current.dev() || held.ino() != current.ino() { + return Err(fail()); + } + } + #[cfg(not(unix))] + let _ = (held, current); + if lock_marker(lock).map_err(|_| fail())? != lock_marker(&named).map_err(|_| fail())? { + return Err(fail()); + } + let after = std::fs::symlink_metadata(&path).map_err(|_| fail())?; + if is_link_like(&after) || !after.file_type().is_file() { + return Err(fail()); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if held.dev() != after.dev() || held.ino() != after.ino() { + return Err(fail()); + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn lock_marker(file: &std::fs::File) -> std::io::Result> { + use std::io::{Read, Seek}; + let mut file = file.try_clone()?; + file.seek(std::io::SeekFrom::Start(0))?; + let mut bytes = Vec::new(); + file.take(129).read_to_end(&mut bytes)?; + if bytes.len() > 128 { + return Err(std::io::Error::other("invalid recovery lock marker")); + } + Ok(bytes) +} + +/// A run's folder that was removed while it runs is made again by the next save: so is its lock. +#[cfg(not(target_arch = "wasm32"))] +fn relock(dir: &Path, created: bool) -> Result<()> { + let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(()) }; + validate_recovery_dir(&run.root)?; + #[cfg(unix)] + let directory = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + // Reclaim a newly recreated folder only when this operation actually created it. Existing + // replacements, including ones without a lock, may contain somebody else's documents. + let same_directory = { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let held = run.directory.metadata().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + held.dev() == directory.dev() && held.ino() == directory.ino() + } + #[cfg(not(unix))] + { + // Without stable directory identity, a valid held lock is the proof of ownership. + // A missing lock cannot authorize an existing folder, even on coarse-timestamp disks. + validate_lock(dir, &run.lock).is_ok() + } + }; + if !created && !same_directory { + return Err(EngineError::Other(format!("{}: this run no longer owns its recovery folder", dir.display()))); + } + match std::fs::symlink_metadata(dir.join(LOCK)) { + Ok(_) => return validate_lock(dir, &run.lock), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + let lock = new_lock(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + lock.try_lock().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + validate_lock(dir, &lock)?; + #[cfg(unix)] + let replacement_directory = std::fs::File::open(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let held = replacement_directory.metadata().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + if held.dev() != directory.dev() || held.ino() != directory.ino() { + return Err(EngineError::Other(format!("{}: recovery folder changed while restoring its lock", dir.display()))); + } + } + validate_lock(dir, &lock)?; + run.lock = lock; + #[cfg(unix)] + { + run.directory = replacement_directory; + } + Ok(()) +} + +/// Move into `own`, this run's folder, what is left to recover beside it: the entries of runs +/// that have ended, and those written straight into the recovery folder before runs had folders. +#[cfg(not(target_arch = "wasm32"))] +fn take_over(own: &Path) { + let root = { + let runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(run) = runs.iter().find(|r| r.dir == own) else { return }; + run.root.clone() + }; + move_entries(&root, own); + let Ok(rd) = std::fs::read_dir(&root) else { return }; + for e in rd.flatten() { + let dir = e.path(); + if dir == own || !e.file_name().to_string_lossy().starts_with("run-") || validate_recovery_dir(&dir).is_err() { + continue; + } + // Opened for writing: over NFS an exclusive lock on a file opened for reading is refused. + let Ok(metadata) = std::fs::symlink_metadata(dir.join(LOCK)) else { continue }; + if is_link_like(&metadata) || !metadata.file_type().is_file() { + continue; + } + let Ok(lock) = std::fs::OpenOptions::new().read(true).write(true).open(dir.join(LOCK)) else { continue }; + match lock.try_lock() { + // Free: the run has ended. Holding it keeps another starting app out. + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => continue, + Err(std::fs::TryLockError::Error(e)) => { + log::warn!("recovery data in {} is left there: {e}", dir.display()); + continue; + } + } + if let Err(e) = validate_lock(&dir, &lock) { + log::warn!("recovery data in {} is left there: {e}", dir.display()); + continue; + } + move_entries(&dir, own); + remove_emptied(&dir); + } +} + +/// Remove the folder of a run that ended, with what the app left in it beside documents. A +/// document still in it, or a file the app did not write, keeps the folder as it is. +#[cfg(not(target_arch = "wasm32"))] +fn remove_emptied(dir: &Path) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + let mut left = Vec::new(); + for e in rd { + let Ok(e) = e else { return }; + let path = e.path(); + let name = e.file_name(); + let name = name.to_string_lossy(); + let numbered = path.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.parse::().is_ok()); + // Metadata whose document has gone, and a file a crash left half written. + if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) { + left.push(path); + } else if name != LOCK { + return; + } + } + for path in left { + let _ = std::fs::remove_file(path); + } + // The lock last: until then the folder is still that of a run that ended. + let _ = std::fs::remove_file(dir.join(LOCK)); + let _ = std::fs::remove_dir(dir); +} + +#[cfg(not(target_arch = "wasm32"))] +fn move_entries(from: &Path, own: &Path) { + for (uid, _) in list(from) { + let (doc, meta) = files(from, uid); + // Under a uid no document of this run has or gets. + let (own_doc, own_meta) = files(own, crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + // The document first: its metadata is optional. + if std::fs::rename(&doc, &own_doc).is_ok() { + let _ = std::fs::rename(&meta, &own_meta); + } + } +} + fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { (dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json"))) } @@ -38,7 +332,7 @@ fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { /// Remove a document's recovery entry (it was saved or closed). pub fn discard(dir: &Path, uid: u64) { #[cfg(not(target_arch = "wasm32"))] - if validate_recovery_dir(dir).is_err() { + if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() { return; } let (d, m) = files(dir, uid); @@ -46,62 +340,172 @@ pub fn discard(dir: &Path, uid: u64) { let _ = std::fs::remove_file(m); } -/// Write recovery entries for the dirty documents (and drop those of clean ones). Returns how -/// many were written. +/// Consume this document's checkpoint provenance after Save/Close/Revert. A default-directory +/// uid may also be another live recovered document's retained source after a failed republish. +pub(crate) fn discard_document(s: &mut Session, index: usize, default_dir: Option<&Path>) { + let Some(doc) = s.docs.get_mut(index) else { return }; + let uid = doc.uid; + // A completed Save/Close/Revert consumes the provenance, even when removal fails. A stale + // source uid must never become a future claim on another document's checkpoint. + let mut entries = std::mem::take(&mut doc.recovery_entries); + if let Some(dir) = default_dir + && !entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, dir)) + { + entries.push((dir.to_path_buf(), uid)); + } + for (dir, uid) in entries { + // Session ownership governs deletion as well as writes: never consume another live + // document's only source merely because its old uid matches this unrelated document. + if !s.docs.iter().enumerate().any(|(other_index, owner)| { + other_index != index && owner.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, &dir)) + }) { + discard(&dir, uid); + } + } +} + +const MAX_RECOVERY_ENTRIES: usize = 16; + +fn same_directory(a: &Path, b: &Path) -> bool { + a == b || std::fs::canonicalize(a).ok().zip(std::fs::canonicalize(b).ok()).is_some_and(|(a, b)| a == b) +} + +fn has_checkpoint(doc: &DocState, dir: &Path) -> bool { + doc.recovery_entries.iter().any(|(entry_dir, uid)| *uid == doc.uid && same_directory(entry_dir, dir)) +} + +fn protect_retained_sources(s: &Session, dir: &Path) -> Result<()> { + for document in &s.docs { + if let Some(owner) = s.docs.iter().find(|owner| { + owner.uid != document.uid && owner.recovery_entries.iter().any(|(entry_dir, uid)| *uid == document.uid && same_directory(entry_dir, dir)) + }) { + return Err(EngineError::Other(format!("save or close recovered document {} before reusing its recovery folder", owner.title()))); + } + } + Ok(()) +} + +fn prepare_save(s: &Session, dir: &Path) -> Result<()> { + // Check before any write or deletion: a failed republish can retain a source whose uid + // coincides with an unrelated live document in a manually configured recovery directory. + protect_retained_sources(s, dir)?; + let created = secure_recovery_dir(dir)?; + #[cfg(not(target_arch = "wasm32"))] + relock(dir, created)?; + #[cfg(target_arch = "wasm32")] + let _ = created; + Ok(()) +} + +/// Write recovery entries for dirty documents and drop clean checkpoints. Returns how many +/// were written. Command callers use the tracked variant without configuring automatic recovery. pub fn save(s: &Session, dir: &Path) -> Result { - secure_recovery_dir(dir)?; + prepare_save(s, dir)?; let mut n = 0; for d in &s.docs { if !d.is_dirty() { discard(dir, d.uid); continue; } - let (doc, meta) = files(dir, d.uid); - let bytes = crate::cmd::to_bytes(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())); - // Write then rename, so a crash mid-write never leaves a torn file. - write_recovery_file(&doc, &bytes)?; - let m = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()}); - write_recovery_file(&meta, m.to_string().as_bytes())?; + write_entry(dir, d)?; n += 1; } Ok(n) } +/// Command-path checkpointing also records explicitly selected directories, without configuring +/// automatic recovery. The immutable public helper remains available for fixtures and callers. +pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result { + // Reject the complete batch before writing rather than silently losing old ownership records. + for document in &s.docs { + if document.is_dirty() && !has_checkpoint(document, dir) && document.recovery_entries.len() >= MAX_RECOVERY_ENTRIES { + return Err(EngineError::Other( + "too many recovery checkpoints; save or close the document before choosing another recovery folder".into(), + )); + } + } + prepare_save(s, dir)?; + let mut n = 0; + for index in 0..s.docs.len() { + if s.docs.get(index).is_some_and(|document| !document.is_dirty()) { + discard_document(s, index, Some(dir)); + continue; + } + let Some(document) = s.docs.get_mut(index) else { continue }; + let (doc, meta) = files(dir, document.uid); + let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref())) + .map_err(|e| EngineError::Other(e.to_string()))?; + write_recovery_file(&doc, &bytes)?; + // The primary is useful even if optional metadata fails next. Track it immediately. + if !has_checkpoint(document, dir) { + document.recovery_entries.push((dir.to_path_buf(), document.uid)); + } + let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()}); + write_recovery_file(&meta, metadata.to_string().as_bytes())?; + n += 1; + } + Ok(n) +} + +/// Publish a document's recovery entry before its optional metadata. +fn write_entry(dir: &Path, d: &DocState) -> Result<()> { + let (doc, meta) = files(dir, d.uid); + let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?; + write_recovery_file(&doc, &bytes)?; + let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()}); + write_recovery_file(&meta, metadata.to_string().as_bytes()) +} + #[cfg(not(target_arch = "wasm32"))] -fn secure_recovery_dir(dir: &Path) -> Result<()> { +fn secure_recovery_dir(dir: &Path) -> Result { match std::fs::symlink_metadata(dir) { - Ok(_) => return validate_recovery_dir(dir), + Ok(_) => return validate_recovery_dir(dir).map(|()| false), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), } - // Set the mode while creating directories so existing caller-owned directories are never - // chmodded. DirBuilder applies this mode only to components it creates; the process umask may - // make it more restrictive. + // Create parent directories privately without changing existing caller-owned permissions. + // Create the leaf separately so a creation race can never be mistaken for our own folder. #[cfg(unix)] let create_result = { use std::os::unix::fs::DirBuilderExt; - - let mut builder = std::fs::DirBuilder::new(); - builder.recursive(true).mode(0o700).create(dir) + if let Some(parent) = dir.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(parent) + .map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + } + std::fs::DirBuilder::new().mode(0o700).create(dir) }; #[cfg(not(unix))] - let create_result = std::fs::create_dir_all(dir); - - create_result.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; - - // Re-check after creation. This also fails closed if another process won a creation race with - // a symlink or, on Windows, any reparse-point leaf. - validate_recovery_dir(dir) + let create_result = { + if let Some(parent) = dir.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + } + std::fs::create_dir(dir) + }; + let created = match create_result { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false, + Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), + }; + validate_recovery_dir(dir)?; + Ok(created) } #[cfg(target_arch = "wasm32")] -fn secure_recovery_dir(_dir: &Path) -> Result<()> { +fn secure_recovery_dir(_dir: &Path) -> Result { Err(EngineError::Other("recovery files are unavailable on the web".into())) } #[cfg(not(target_arch = "wasm32"))] fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> { + publish_recovery_file(path, bytes, true) +} + +#[cfg(not(target_arch = "wasm32"))] +fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> { let Some(dir) = path.parent() else { return Err(EngineError::Other(format!("{} has no parent folder", path.display()))); }; @@ -121,7 +525,8 @@ fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> { // Validate the non-followed leaf again immediately before renaming the randomized temporary // file to its fixed sibling destination. validate_recovery_dir(dir)?; - tmp.persist(path).map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?; + let published = if replace { tmp.persist(path) } else { tmp.persist_noclobber(path) }; + published.map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?; sync_recovery_dir(dir)?; Ok(()) } @@ -181,11 +586,50 @@ fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> { Err(EngineError::Other("recovery files are unavailable on the web".into())) } +#[cfg(target_arch = "wasm32")] +fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<()> { + Err(EngineError::Other("recovery files are unavailable on the web".into())) +} + +/// Canonical keys are only for alias comparison; operational paths stay unchanged so run +/// ownership checks still find the directory registered by the native profile. +fn directory_key(dir: &Path) -> Result { + std::fs::canonicalize(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display()))) +} + +/// Include metadata-only entries as occupied, so publication cannot attach a new document to +/// somebody else's orphan metadata. No filename-derived maximum changes the uid allocator. +fn occupied_uids(dir: &Path) -> Result> { + let entries = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let mut ids = std::collections::HashSet::new(); + for entry in entries { + let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let path = entry.path(); + if matches!(path.extension().and_then(|s| s.to_str()), Some("json" | "designcraft")) + && let Some(uid) = path.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse().ok()) + { + ids.insert(uid); + } + } + Ok(ids) +} + +fn reserve_uid(state: &mut DocState, occupied: &mut std::collections::HashSet) -> Result<()> { + // At most occupied.len() consecutive candidates can collide. The extra attempt must be free. + for _ in 0..=occupied.len() { + if occupied.insert(state.uid) { + return Ok(()); + } + state.uid = crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + Err(EngineError::Other("no free recovery document uid".into())) +} + /// Recovery entries in `dir`: (uid, metadata). pub fn list(dir: &Path) -> Vec<(u64, Value)> { let mut out = Vec::new(); #[cfg(not(target_arch = "wasm32"))] - if validate_recovery_dir(dir).is_err() { + if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() { return out; } let Ok(rd) = std::fs::read_dir(dir) else { return out }; @@ -207,13 +651,46 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { out } -/// Reopen every recovery entry as an unsaved document (and remove the entries: the new session -/// writes its own). Returns the opened documents' indices. +/// Reopen recovery entries as unsaved documents. Publish each recovered document under its new +/// uid before removing the original, so another crash before the next timer preserves the edits. +/// Entries belonging to documents already open in this session's recovery folder are left alone. +/// Returns the opened documents' indices. pub fn open(s: &mut Session, dir: &Path) -> Result> { + open_with_publisher(s, dir, |path, bytes| publish_recovery_file(path, bytes, false)) +} + +fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result> { #[cfg(not(target_arch = "wasm32"))] - validate_recovery_dir(dir)?; + { + validate_recovery_dir(dir)?; + relock(dir, false)?; + } + let destination = s.recovery_dir.clone().unwrap_or_else(|| dir.to_path_buf()); + let created = secure_recovery_dir(&destination)?; + #[cfg(not(target_arch = "wasm32"))] + relock(&destination, created)?; + #[cfg(target_arch = "wasm32")] + let _ = created; + let source_key = directory_key(dir)?; + let destination_key = directory_key(&destination)?; let mut opened = Vec::new(); - for (uid, meta) in list(dir) { + // For this run's own folder, first bring in what runs that ended left in theirs. + #[cfg(not(target_arch = "wasm32"))] + take_over(dir); + let entries = list(dir); + // Reserve every source entry before opening the first one: publication must never overwrite + // a source still waiting in this list, even after a fresh process restarts its allocator at 1. + let mut occupied = occupied_uids(dir)?; + if source_key != destination_key { + occupied.extend(occupied_uids(&destination)?); + } + occupied.extend(s.docs.iter().map(|d| d.uid)); + for (uid, meta) in entries { + if s.docs.iter().any(|d| { + d.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && directory_key(entry_dir).is_ok_and(|key| key == source_key)) + }) { + continue; + } #[cfg(not(target_arch = "wasm32"))] validate_recovery_dir(dir)?; let (doc, _) = files(dir, uid); @@ -226,12 +703,37 @@ pub fn open(s: &mut Session, dir: &Path) -> Result> { // Skipped font files are logged; the document opens without them. let fonts = path.as_deref().and_then(|p| crate::cmd::load_document_fonts(&mut d, p).0); let mut st = DocState::new(d, path); + reserve_uid(&mut st, &mut occupied)?; + st.recovery_entries.push((dir.to_path_buf(), uid)); st.fonts = fonts; // Unsaved: the copy on disk (if any) is older than what was recovered. st.saved_doc = std::sync::Arc::new((*st.doc).clone()); st.revision += 1; - opened.push(s.add_document(st)); - discard(dir, uid); + let index = s.add_document(st); + opened.push(index); + // New uids are published without replacement, and the original is consumed only after + // both files succeed. Track a published primary even if metadata fails, so Save/Close can + // clean up every snapshot this document actually owns rather than an unrelated same uid. + if let Some(state) = s.docs.get_mut(index) { + let (document, metadata) = files(&destination, state.uid); + let published = designcraft_format::save(state.doc.as_ref()) + .map_err(|e| EngineError::Other(e.to_string())) + .and_then(|bytes| publish(&document, &bytes)); + match published { + Ok(()) => { + state.recovery_entries.push((destination.clone(), state.uid)); + let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()}); + match publish(&metadata, meta.to_string().as_bytes()) { + Ok(()) => { + discard(dir, uid); + state.recovery_entries = vec![(destination.clone(), state.uid)]; + } + Err(e) => log::warn!("recovery entry {uid} kept: {e}"), + } + } + Err(e) => log::warn!("recovery entry {uid} kept: {e}"), + } + } } Ok(opened) } @@ -265,7 +767,8 @@ mod tests { let st = s2.doc().unwrap(); assert!(st.is_dirty()); assert_eq!(st.doc.spreads[0].items.len(), 1); - assert!(list(&dir).is_empty(), "entries are consumed"); + assert_eq!(list(&dir).iter().map(|e| e.0).collect::>(), [st.uid], "the entry follows its reopened document"); + discard(&dir, st.uid); // Saving a document drops its entry. s.recovery_dir = Some(dir.clone()); assert_eq!(save(&s, &dir).unwrap(), 1); @@ -275,6 +778,69 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_recovered_document_survives_a_second_crash_before_autosave() { + let dir = tempfile::tempdir().unwrap(); + let original = unsaved("edits preserved after a second crash"); + assert_eq!(save(&original, dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + drop(reopened); + let mut recovered_again = Session::new(); + assert_eq!(open(&mut recovered_again, dir.path()).unwrap().len(), 1); + assert_eq!(texts(&recovered_again), ["edits preserved after a second crash"]); + assert!(recovered_again.documents().iter().all(DocState::is_dirty)); + assert_eq!(list(dir.path()).len(), 1); + } + + #[test] + fn opening_recovery_again_does_not_duplicate_live_documents() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("one recovered document"), dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + reopened.recovery_dir = Some(dir.path().to_path_buf()); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + assert_eq!(texts(&reopened), ["one recovered document"]); + assert_eq!(list(dir.path()).len(), 1); + } + + /// Directory collisions fail even for privileged runners. Creating the blocker in the + /// publisher occurs after UID reservation, which would otherwise skip an occupied path. + fn open_with_failed_republication(s: &mut Session, dir: &Path) -> Result> { + let mut attempts = 0; + let opened = open_with_publisher(s, dir, |path, bytes| { + assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("designcraft")); + std::fs::create_dir(path).unwrap(); + let result = publish_recovery_file(path, bytes, false); + std::fs::remove_dir(path).unwrap(); + assert!(result.is_err(), "a directory must obstruct actual recovery publication"); + attempts += 1; + result + }); + assert_eq!(attempts, 1, "the fixture must exercise the actual failed publication"); + opened + } + + #[test] + fn a_failed_recovery_rewrite_preserves_the_original() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("keep the original"), dir.path()).unwrap(), 1); + let entries = list(dir.path()); + let original = files(dir.path(), entries[0].0).0; + let bytes = std::fs::read(&original).unwrap(); + let mut reopened = Session::new(); + let result = open_with_failed_republication(&mut reopened, dir.path()); + assert_eq!(result.unwrap().len(), 1); + assert_eq!(texts(&reopened), ["keep the original"]); + assert_eq!(std::fs::read(original).unwrap(), bytes); + assert_eq!(list(dir.path()).len(), 1); + // Its source uid differs from the reopened uid, but explicit Close still discards it. + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + reopened.close_document(0); + assert!(list(dir.path()).is_empty()); + } + #[cfg(unix)] #[test] fn recovery_data_is_private_to_the_user() { @@ -350,6 +916,567 @@ mod tests { assert!(open(&mut Session::new(), &link).is_err()); assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); } + + #[test] + fn a_run_has_a_locked_folder_of_its_own() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + let own = run_dir(&dir).unwrap(); + assert_eq!(own.parent(), Some(dir.as_path())); + assert_eq!(run_dir(&dir).unwrap(), own, "one for the whole run"); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "held while the run lives"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&own).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!(std::fs::metadata(own.join(LOCK)).unwrap().permissions().mode() & 0o777, 0o600); + } + } + + fn failed_republication_session() -> (Session, tempfile::TempDir, u64) { + let root = tempfile::tempdir().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let live_uid = session.docs[0].uid; + let mut original = unsaved("retained old work"); + original.docs[0].uid = live_uid; + save(&original, root.path()).unwrap(); + session.recovery_dir = Some(root.path().to_path_buf()); + let recovered = open_with_failed_republication(&mut session, root.path()); + assert_eq!(recovered.unwrap().len(), 1); + assert_ne!(session.docs[1].uid, live_uid); + (session, root, live_uid) + } + + #[test] + fn retained_source_survives_a_coincident_live_uid_checkpoint_failure() { + let (mut session, root, live_uid) = failed_republication_session(); + let original = std::fs::read(files(root.path(), live_uid).0).unwrap(); + let recovered_uid = session.docs[1].uid; + std::fs::create_dir(files(root.path(), recovered_uid).0).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + assert!(session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); + assert_eq!(std::fs::read(files(root.path(), live_uid).0).unwrap(), original); + } + + #[test] + fn clean_autosave_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + assert!(!session.docs[0].is_dirty()); + assert!(session.docs[1].is_dirty()); + assert!(session.execute("file.recovery.save", &json!({})).is_err()); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[test] + fn closing_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[test] + fn saving_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved-other.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[test] + fn reverting_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + let saved = root.path().join("saved-other.designcraft"); + std::fs::write(&saved, designcraft_format::save(&session.docs[0].doc).unwrap()).unwrap(); + session.docs[0].path = Some(saved.to_string_lossy().into_owned()); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100]})).unwrap(); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[test] + fn retained_source_provenance_is_consumed_before_its_uid_is_reused() { + let (mut session, root, live_uid) = failed_republication_session(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); + let checkpoint = files(root.path(), live_uid).0; + let expected = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 1})).unwrap(); + assert_eq!( + std::fs::read(checkpoint).unwrap(), + expected, + "closing the saved recovered document must not delete another document's checkpoint" + ); + } + + #[test] + fn explicit_checkpoint_tracking_is_bounded_and_deduplicated_before_writes() { + let root = tempfile::tempdir().unwrap(); + let mut session = unsaved("bounded checkpoints"); + for n in 0..MAX_RECOVERY_ENTRIES { + session.execute("file.recovery.save", &json!({"dir": root.path().join(n.to_string()).to_string_lossy()})).unwrap(); + } + let excess = root.path().join("excess"); + assert!(session.execute("file.recovery.save", &json!({"dir": excess.to_string_lossy()})).is_err()); + assert!(!excess.exists(), "reject before creating or writing the next directory"); + let first_alias = root.path().join("0").join("."); + session.execute("file.recovery.save", &json!({"dir": first_alias.to_string_lossy()})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert_eq!(persisted_texts(&root.path().join(n.to_string())), ["bounded checkpoints"]); + } + assert!(session.recovery_dir.is_none()); + session.execute("file.close", &json!({})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert!(list(&root.path().join(n.to_string())).is_empty()); + } + } + + fn restart_fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + let mut first = unsaved("restart first"); + first.docs[0].uid = 1; + save(&first, root.path()).unwrap(); + let mut second = unsaved("restart second"); + second.docs[0].uid = 2; + save(&second, root.path()).unwrap(); + root + } + + fn persisted_texts(dir: &Path) -> Vec { + let mut result = Vec::new(); + for (uid, _) in list(dir) { + let doc = crate::cmd::from_bytes(&std::fs::read(files(dir, uid).0).unwrap()).unwrap(); + result.extend(doc.stories.values().map(|story| story.text.clone())); + } + result.sort(); + result + } + + #[test] + #[ignore = "subprocess helper, executed by the restart recovery regression tests"] + fn restart_recovery_child() { + let source = PathBuf::from(std::env::var("DESIGNCRAFT_TEST_RECOVERY_SOURCE").unwrap()); + let mode = std::env::var("DESIGNCRAFT_TEST_RECOVERY_MODE").unwrap(); + if mode == "occupied_destination" { + let owned_root = tempfile::tempdir().unwrap(); + let destination = run_dir(owned_root.path()).unwrap(); + let mut existing = unsaved("already in destination"); + existing.docs[0].uid = 3; + save(&existing, &destination).unwrap(); + let (existing_doc, existing_meta) = files(&destination, 3); + let doc_bytes = std::fs::read(&existing_doc).unwrap(); + let meta_bytes = std::fs::read(&existing_meta).unwrap(); + let mut session = Session::new(); + session.recovery_dir = Some(destination.clone()); + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|document| document.uid != 3)); + assert_eq!(persisted_texts(&destination), ["already in destination", "restart first", "restart second"]); + assert!(list(&source).is_empty()); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert_eq!(list(&destination).len(), 1); + assert_eq!(std::fs::read(existing_doc).unwrap(), doc_bytes); + assert_eq!(std::fs::read(existing_meta).unwrap(), meta_bytes); + return; + } + let mut session = Session::new(); + if mode == "advance" || mode == "coincident_live" { + session.execute("file.new", &json!({})).unwrap(); + assert_eq!(session.docs[0].uid, 1); + } + let owned_root; + let destination = match mode.as_str() { + "cross_dir" => { + owned_root = tempfile::tempdir().unwrap(); + let own = run_dir(owned_root.path()).unwrap(); + session.recovery_dir = Some(own.clone()); + own + } + "alias" => { + let alias = source.join("."); + session.recovery_dir = Some(alias.clone()); + alias + } + "coincident_live" => { + session.recovery_dir = Some(source.clone()); + source.clone() + } + _ => source.clone(), + }; + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert_eq!(persisted_texts(&destination), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|d| d.uid > 0 && d.uid < u64::MAX)); + if matches!(mode.as_str(), "cross_dir" | "manual" | "alias" | "revert") { + let was_disabled = session.recovery_dir.is_none(); + if mode == "cross_dir" { + assert!(list(&source).is_empty()); + } + // A copy does not consume recovery. A successful Save and a Close do. + session.execute("file.saveACopy", &json!({"path": source.join("copy.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 1); + if mode == "revert" { + session.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "text", "text": "after saving"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": destination.to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(list(&destination).len(), 1); + } + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert!(list(&destination).is_empty()); + assert!(list(&source).is_empty()); + assert_eq!(session.recovery_dir.is_none(), was_disabled, "manual recovery must not configure autosave"); + } + } + + fn run_restart_case(mode: &str) { + let source = restart_fixture(); + if mode == "max_uid" { + let (doc, meta) = files(source.path(), 2); + let (max_doc, max_meta) = files(source.path(), u64::MAX); + std::fs::rename(doc, max_doc).unwrap(); + std::fs::rename(meta, max_meta).unwrap(); + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "recovery::tests::restart_recovery_child", "--ignored", "--nocapture"]) + .env("DESIGNCRAFT_TEST_RECOVERY_SOURCE", source.path()) + .env("DESIGNCRAFT_TEST_RECOVERY_MODE", mode) + .output() + .unwrap(); + assert!( + output.status.success(), + "fresh child failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let child_stdout = String::from_utf8_lossy(&output.stdout); + assert!(child_stdout.contains("test result: ok. 1 passed; 0 failed;"), "the fresh-process helper did not run exactly once: {child_stdout}"); + } + + #[test] + fn restart_uid_collision_cannot_delete_the_new_snapshot() { + run_restart_case("fresh"); + } + #[test] + fn restart_uid_collision_cannot_overwrite_an_unopened_entry() { + run_restart_case("advance"); + } + #[test] + fn cross_directory_recovery_uses_the_configured_destination() { + run_restart_case("cross_dir"); + } + #[test] + fn cross_directory_recovery_preserves_occupied_destination_entries() { + run_restart_case("occupied_destination"); + } + #[test] + fn manual_recovery_save_and_close_clean_up_without_enabling_autosave() { + run_restart_case("manual"); + } + #[test] + fn recovery_directory_aliases_do_not_delete_new_snapshots() { + run_restart_case("alias"); + } + #[test] + fn a_maximum_source_uid_does_not_advance_or_overflow_the_allocator() { + run_restart_case("max_uid"); + } + #[test] + fn a_coincident_live_uid_is_not_recovered_entry_provenance() { + run_restart_case("coincident_live"); + } + #[test] + fn manual_recovery_revert_removes_its_previous_checkpoint() { + run_restart_case("revert"); + } + + /// The recovery folder was cleared while the app runs: the next save makes the run's folder + /// again, and its lock with it. + #[cfg(unix)] + #[test] + fn a_removed_run_folder_is_made_again_with_its_lock() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_dir_all(&own).unwrap(); + assert_eq!(save(&dirty_session(), &own).unwrap(), 1); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock))); + } + + /// Replacing the name of a live run's lock must not authorize writes under the old handle. + #[cfg(unix)] + #[test] + fn save_rejects_a_run_whose_lock_was_replaced() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert!(list(&own).is_empty(), "another owner's folder must not receive our recovery data"); + } + + /// A blocked attempt to recreate a run's lock must stop the save before it writes documents. + #[cfg(unix)] + #[test] + fn save_rejects_a_run_whose_lock_cannot_be_recreated() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + std::fs::create_dir(own.join(LOCK)).unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert!(list(&own).is_empty()); + } + + #[cfg(unix)] + #[test] + fn discarding_a_document_does_not_touch_a_replaced_runs_entries() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + let (doc, meta) = files(&own, 1); + std::fs::write(&doc, b"the replacement owner's document").unwrap(); + std::fs::write(&meta, b"{}").unwrap(); + discard(&own, 1); + assert_eq!(std::fs::read(doc).unwrap(), b"the replacement owner's document"); + assert!(meta.exists()); + } + + #[cfg(unix)] + #[test] + fn saving_a_run_rejects_a_symlinked_lock() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let unrelated = root.path().join("unrelated"); + std::fs::write(&unrelated, b"unrelated file").unwrap(); + std::os::unix::fs::symlink(&unrelated, own.join(LOCK)).unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert_eq!(std::fs::read(unrelated).unwrap(), b"unrelated file"); + } + + #[cfg(unix)] + #[test] + fn a_replaced_run_directory_is_not_claimed_or_overwritten() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + let session = dirty_session(); + let uid = session.docs[0].uid; + std::fs::remove_dir_all(&own).unwrap(); + std::fs::create_dir(&own).unwrap(); + let (doc, _) = files(&own, uid); + std::fs::write(&doc, b"another owner's document").unwrap(); + assert!(save(&session, &own).is_err()); + assert_eq!(std::fs::read(doc).unwrap(), b"another owner's document"); + assert!(!own.join(LOCK).exists()); + } + + #[cfg(unix)] + #[test] + fn a_run_gets_no_folder_in_a_symlinked_recovery_folder() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("destination"); + let link = root.path().join("recovery"); + std::fs::create_dir(&destination).unwrap(); + symlink(&destination, &link).unwrap(); + + assert!(run_dir(&link).is_err()); + assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); + } + + fn unsaved(text: &str) -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "text", "text": text})).unwrap(); + session + } + + fn texts(s: &Session) -> Vec { + let mut texts: Vec = s.documents().iter().flat_map(|d| d.doc.stories.values().map(|st| st.text.clone())).collect(); + texts.sort(); + texts + } + + /// What a run that has ended leaves: its folder with its lock, free now, and the entry of + /// one unsaved document, numbered `uid` as that process numbered it. + fn run_left(root: &Path, name: &str, text: &str, uid: u64) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(LOCK), b"").unwrap(); + let mut s = unsaved(text); + s.docs[0].uid = uid; + assert_eq!(save(&s, &dir).unwrap(), 1); + dir + } + + /// Every process numbers its first document 1: two running apps both wrote `1.designcraft` + /// into the one recovery folder, and only the document written last was recovered. + #[test] + fn documents_of_two_apps_that_ended_are_both_recovered() { + let root = tempfile::tempdir().unwrap(); + let first = run_left(root.path(), "run-7-0", "first app", 1); + let second = run_left(root.path(), "run-8-0", "second app", 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert_eq!(texts(&s), ["first app", "second app"]); + assert!(s.documents().iter().all(DocState::is_dirty)); + assert!(!first.exists() && !second.exists(), "nothing is left of the runs that ended"); + } + + /// An app that started reopened the unsaved documents of one still running, and removed + /// its entries. + #[test] + fn a_running_apps_recovery_data_is_left_alone() { + let root = tempfile::tempdir().unwrap(); + let running = run_left(root.path(), "run-7-0", "still open", 1); + let lock = std::fs::File::open(running.join(LOCK)).unwrap(); + lock.try_lock().unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert_eq!(list(&running).len(), 1); + // Once it has ended, its document is recovered. + drop(lock); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["still open"]); + assert!(!running.exists()); + } + + /// A process id comes round again: the folder a run left under it is not this run's. + #[test] + fn a_folder_left_under_this_process_id_is_not_reused() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), &format!("run-{}-0", std::process::id()), "left behind", 1); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["left behind"]); + } + + /// Entries written straight into the recovery folder, as before runs had folders. + #[test] + fn recovery_data_of_an_earlier_version_is_recovered() { + let root = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("earlier version"), root.path()).unwrap(), 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["earlier version"]); + assert!(list(root.path()).is_empty()); + } + + /// An entry that can't be opened stays on disk, in this run's folder now. + #[test] + fn an_unreadable_entry_of_a_run_that_ended_is_kept() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), "run-7-0", "readable", 1); + std::fs::write(left.join("2.designcraft"), b"not a document").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert!(list(&own).iter().any(|e| std::fs::read(files(&own, e.0).0).unwrap() == b"not a document")); + assert!(!left.exists()); + } + + /// Only a folder named as a run's, with a lock that is free, is a run that ended. + #[test] + fn what_is_no_run_that_ended_is_passed_over() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("run-1-0"), b"a file").unwrap(); + // An app that is starting has made its folder and not yet its lock. + let starting = root.path().join("run-2-0"); + std::fs::create_dir(&starting).unwrap(); + let other = run_left(root.path(), "backup", "not a run", 1); + #[cfg(unix)] + let (_elsewhere, linked) = { + let elsewhere = tempfile::tempdir().unwrap(); + let linked = run_left(elsewhere.path(), "run-3-0", "behind a link", 1); + std::os::unix::fs::symlink(&linked, root.path().join("run-3-0")).unwrap(); + (elsewhere, linked) + }; + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert!(starting.exists()); + assert_eq!(list(&other).len(), 1); + #[cfg(unix)] + assert!(list(&linked).len() == 1 && root.path().join("run-3-0").is_symlink()); + } + + /// The metadata goes with its document: a document taken over remembers its file. + #[test] + fn a_document_taken_over_remembers_its_file() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("layout.designcraft"); + let recovery = root.path().join("recovery"); + let left = recovery.join("run-7-0"); + std::fs::create_dir_all(&left).unwrap(); + std::fs::write(left.join(LOCK), b"").unwrap(); + let mut a = unsaved("saved once"); + a.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); + a.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(save(&a, &left).unwrap(), 1); + let own = run_dir(&recovery).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.to_string_lossy().as_ref())); + } + + /// The folder of a run that ended goes with what the app left in it. A file the app did not + /// write keeps the folder, and is not deleted. + #[test] + fn only_what_the_app_left_goes_with_the_folder_of_a_run_that_ended() { + let root = tempfile::tempdir().unwrap(); + let emptied = run_left(root.path(), "run-7-0", "moved", 1); + std::fs::write(emptied.join("9.json"), b"{}").unwrap(); + std::fs::write(emptied.join(".designcraft-recovery-abc.tmp"), b"half written").unwrap(); + let kept = run_left(root.path(), "run-8-0", "moved too", 1); + std::fs::write(kept.join("copy.designcraft"), b"a copy someone put here").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert!(!emptied.exists()); + assert_eq!(std::fs::read(kept.join("copy.designcraft")).unwrap(), b"a copy someone put here"); + assert!(kept.join(LOCK).exists(), "still the folder of a run that ended"); + } + + /// A folder that is there is another app's or was left behind, lock or no lock: a run's own + /// is one it made itself. + #[test] + fn a_folder_that_is_there_is_not_made_this_runs() { + let root = tempfile::tempdir().unwrap(); + let left = root.path().join(format!("run-{}-0", std::process::id())); + std::fs::create_dir(&left).unwrap(); + std::fs::write(left.join("1.designcraft"), b"left with no lock").unwrap(); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + assert!(!left.join(LOCK).exists()); + assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock"); + } } #[cfg(test)] diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index a447cb6bb..d2304e651 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -9,7 +9,8 @@ //! //! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`), //! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and -//! lines starting with `#` or `//` are ignored. +//! lines starting with `#` or `//` are ignored. A leading byte order mark and CRLF line endings are +//! accepted. use serde_json::{Value, json}; @@ -22,7 +23,9 @@ pub struct Step { /// Parse script text (see the module docs). pub fn parse(text: &str) -> Result, String> { - let t = text.trim(); + // Windows tools save "UTF-8 with BOM"; `trim` keeps U+FEFF, which then became part of the + // first command id. + let t = text.trim_start_matches('\u{feff}').trim(); if t.starts_with('[') { let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?; return v @@ -174,6 +177,17 @@ mod tests { assert!(parse("frame.create {bad").unwrap_err().contains("line 1")); } + /// Windows editors and PowerShell 5.1 save "UTF-8 with BOM" with CRLF line endings; the BOM + /// became part of the first command id (#654). + #[test] + fn a_leading_bom_and_crlf_parse_like_plain_text() { + let plain = "file.new {\"width\": 100}\nframe.create {\"rect\": [0, 0, 10, 10]}\n"; + let windows = format!("\u{feff}{}", plain.replace('\n', "\r\n")); + assert_eq!(parse(&windows).unwrap(), parse(plain).unwrap()); + let array = r#"[{"command": "file.new"}]"#; + assert_eq!(parse(&format!("\u{feff}{array}")).unwrap(), parse(array).unwrap()); + } + #[test] fn resolves_references() { let results = vec![json!({"id": 8, "story": 9, "ids": [4, 5]}), json!({"name": "A"})]; diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 45861347d..3934aabec 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -399,3 +399,125 @@ fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() { } } } + +#[test] +fn split_selected_row_or_column_splits_both_cells() { + for horizontal in [true, false] { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let params = if horizontal { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", ¶ms).unwrap(); + let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" }; + s.execute(command, &json!({})).unwrap(); + let table = table_for(&s, sid, tid); + assert_eq!((table.nrows(), table.ncols()), if horizontal { (3, 2) } else { (2, 3) }); + for r in 0..2 { + for c in 0..2 { + assert_eq!(table.owner(r, c), (r, c), "both selected cells have independent halves"); + } + } + check(&s); + } +} + +#[test] +fn split_selected_block_and_merged_cells_preserves_geometry_and_history() { + for horizontal in [true, false] { + for merged in [false, true] { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 4, "cols": 4, "width": 240})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setRowHeight", &json!({"table": tid, "height": 60, "mode": "exactly"})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Neighbor", "fill": "[Paper]", "insets": 3})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Chosen", "fill": "[Black]", "tint": 0.15, "insets": 2})).unwrap(); + s.execute("style.cell.apply", &json!({"table": tid, "name": "Neighbor"})).unwrap(); + for r in 0..4 { + for c in 0..4 { + s.execute("table.setCell", &json!({"row": r, "col": c, "text": format!("{r},{c}")})).unwrap(); + } + } + let range = if !merged { + doc::CellRange::new(1, 1, 2, 2) + } else if horizontal { + doc::CellRange::new(0, 0, 2, 1) + } else { + doc::CellRange::new(0, 0, 1, 2) + }; + let selection = json!({"rows": [range.r0, range.r1], "cols": [range.c0, range.c1]}); + s.execute("table.select", &selection).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Chosen"})).unwrap(); + if merged { + let merge = if horizontal { json!({"rows": [0, 2], "cols": [1, 1]}) } else { json!({"rows": [1, 1], "cols": [0, 2]}) }; + s.execute("table.merge", &merge).unwrap(); + s.execute("table.select", &selection).unwrap(); + } + let before_doc = s.doc().unwrap().doc.clone(); + let before_selection = s.doc().unwrap().selection.clone(); + let before = table_for(&s, sid, tid).clone(); + let before_layout = s.cache.get(&before_doc, doc::StoryId(sid), None); + let before_fragment = before_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); + let undo_len = s.doc().unwrap().history.undo.len(); + let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" }; + s.execute(command, &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1, "one split command creates one undo entry"); + let after_doc = s.doc().unwrap().doc.clone(); + let after_selection = s.doc().unwrap().selection.clone(); + let after = table_for(&s, sid, tid).clone(); + let after_layout = s.cache.get(&after_doc, doc::StoryId(sid), None); + let after_fragment = after_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); + assert_eq!(after_fragment.rect, before_fragment.rect, "splitting does not resize the table"); + assert_eq!(after_fragment.cells.len(), before_fragment.cells.len() + 4, "four original owners split once"); + for placed in &before_fragment.cells { + let original = before.cell(placed.row, placed.col).unwrap(); + let matches: Vec<_> = after_fragment.cells.iter().filter(|c| c.source == placed.source).collect(); + assert_eq!(matches.len(), 1, "original cell text survives once: {}", placed.source); + let surviving = matches[0]; + let cell = after.cell(surviving.row, surviving.col).unwrap(); + let mut normalized = cell.clone(); + normalized.row_span = original.row_span; + normalized.col_span = original.col_span; + assert_eq!(&normalized, original, "existing cell text and formatting are preserved"); + if !range.contains(placed.row, placed.col) { + assert_eq!(surviving.rect, placed.rect, "unselected neighbor geometry is unchanged"); + continue; + } + let second_addr = if horizontal { + (surviving.row + cell.row_span as usize, surviving.col) + } else { + (surviving.row, surviving.col + cell.col_span as usize) + }; + let second = after.cell(second_addr.0, second_addr.1).unwrap(); + assert_eq!(after.owner(second_addr.0, second_addr.1), second_addr); + assert!(second.text.is_empty()); + assert_eq!(second.style, original.style); + assert_eq!(second.insets, original.insets); + assert_eq!(second.fill, original.fill); + assert_eq!(second.fill_tint, original.fill_tint); + let second_rect = after_fragment.cell(second_addr.0, second_addr.1).unwrap().rect; + assert_eq!(surviving.rect.union(second_rect), placed.rect, "the halves exactly cover the original cell"); + if horizontal { + assert_eq!(surviving.rect.y1, second_rect.y0); + } else { + assert_eq!(surviving.rect.x1, second_rect.x0); + } + } + let text = s.doc().unwrap().selection.text.unwrap(); + let address = text.cell.unwrap(); + assert_eq!(after.owner(address.row, address.col), (address.row, address.col)); + assert!(text.focus <= after.cell(address.row, address.col).unwrap().text.len()); + let cells = s.doc().unwrap().selection.cells.unwrap(); + assert!(cells.range.r1 < after.nrows() && cells.range.c1 < after.ncols()); + check(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before_doc)); + assert_eq!(s.doc().unwrap().selection, before_selection); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &after_doc)); + assert_eq!(s.doc().unwrap().selection, after_selection); + let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc); + let reopened = crate::cmd::file_from(&bytes).unwrap(); + reopened.check().unwrap(); + assert_eq!(reopened.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after); + } + } +} diff --git a/crates/engine/tests/book_safety.rs b/crates/engine/tests/book_safety.rs new file mode 100644 index 000000000..74c96815a --- /dev/null +++ b/crates/engine/tests/book_safety.rs @@ -0,0 +1,188 @@ +#![cfg(not(target_arch = "wasm32"))] + +use designcraft_engine::Session; +use serde_json::json; +use std::path::Path; +fn dir(name: &str) -> tempfile::TempDir { + tempfile::Builder::new().prefix(&format!("dc-book-safety-{name}-")).tempdir().unwrap() +} +fn document(start: u32, custom: bool) -> designcraft_doc::Document { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages":2})).unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + d.sections.first_mut().unwrap().start_number = Some(start); + if custom { + let mut style = d.styles.paragraph.get(1).unwrap().clone(); + style.name = "Synthetic Book Style".into(); + d.styles_mut().paragraph.push(style); + } + d +} +fn native(p: &Path, start: u32, custom: bool) { + std::fs::write(p, designcraft_format::save(&document(start, custom)).unwrap()).unwrap(); +} +fn idml(p: &Path, start: u32) { + std::fs::write(p, designcraft_idml::export_idml(&document(start, false))).unwrap(); + assert!(designcraft_idml::import_idml(&std::fs::read(p).unwrap()).is_ok()); +} +fn book(d: &Path, chapters: &[&Path]) -> Session { + let mut s = Session::new(); + s.execute("book.new", &json!({"path":d.join("book.dcbook").to_string_lossy()})).unwrap(); + for p in chapters { + s.execute("book.add", &json!({"path":p.to_string_lossy()})).unwrap(); + } + s +} +#[test] +fn late_missing_chapter_does_not_partially_paginate_earlier_chapters() { + let d = dir("late-missing"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&first, 9, false); + native(&later, 1, false); + let mut s = book(d.path(), &[&first, &later]); + let before = std::fs::read(&first).unwrap(); + std::fs::remove_file(later).unwrap(); + let result = s.execute("book.paginate", &json!({})); + assert!(result.is_err()); + let after = std::fs::read(&first).unwrap(); + assert!( + before == after, + "book.paginate changed the first chapter before error: {result:?}; new first number {:?}", + designcraft_format::load(&after).unwrap().sections.first().unwrap().start_number + ); +} +#[test] +fn late_corrupt_chapter_does_not_partially_sync_earlier_chapters() { + let d = dir("late-corrupt"); + let source = d.path().join("source.designcraft"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&source, 1, true); + native(&first, 1, false); + native(&later, 1, false); + let mut s = book(d.path(), &[&source, &first, &later]); + let before = std::fs::read(&first).unwrap(); + std::fs::write(later, b"not a document").unwrap(); + let result = s.execute("book.syncStyles", &json!({})); + assert!(result.is_err()); + let after = std::fs::read(&first).unwrap(); + assert!( + before == after, + "book.syncStyles changed the first target before error: {result:?}; copied custom style {}", + designcraft_format::load(&after).unwrap().styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style") + ); +} +#[test] +fn paginate_does_not_replace_imported_idml_with_native_format() { + let d = dir("idml-paginate"); + let chapter = d.path().join("chapter.idml"); + idml(&chapter, 9); + let mut s = book(d.path(), &[&chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.paginate", &json!({})); + let after = std::fs::read(&chapter).unwrap(); + assert!( + before == after, + "book.paginate rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}", + designcraft_idml::import_idml(&after).is_ok(), + designcraft_format::load(&after).is_ok() + ); +} +#[test] +fn style_sync_does_not_replace_imported_idml_with_native_format() { + let d = dir("idml-sync"); + let source = d.path().join("source.designcraft"); + let chapter = d.path().join("chapter.idml"); + native(&source, 1, true); + idml(&chapter, 1); + let mut s = book(d.path(), &[&source, &chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.syncStyles", &json!({})); + let after = std::fs::read(&chapter).unwrap(); + assert!( + before == after, + "book.syncStyles rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}", + designcraft_idml::import_idml(&after).is_ok(), + designcraft_format::load(&after).is_ok() + ); +} + +#[test] +fn an_open_unsaved_chapter_blocks_batch_writes_before_any_files_change() { + let d = dir("open-unsaved"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&first, 9, false); + native(&later, 5, false); + let mut s = book(d.path(), &[&first, &later]); + s.execute("file.open", &json!({"path":later.to_string_lossy()})).unwrap(); + s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":"Unsaved chapter edit"})).unwrap(); + let first_before = std::fs::read(&first).unwrap(); + let later_before = std::fs::read(&later).unwrap(); + let revision = s.doc().unwrap().revision; + let result = s.execute("book.paginate", &json!({})); + assert!( + result.is_err(), + "book.paginate accepted an open dirty chapter: {result:?}; first changed {}, open backing file changed {}", + std::fs::read(&first).unwrap() != first_before, + std::fs::read(&later).unwrap() != later_before + ); + assert_eq!(std::fs::read(&first).unwrap(), first_before); + assert_eq!(std::fs::read(&later).unwrap(), later_before); + assert_eq!(s.doc().unwrap().revision, revision); + assert!(s.doc().unwrap().is_dirty()); + let e = result.unwrap_err().to_string(); + assert!(e.contains("later.designcraft") && e.contains("close"), "{e}"); +} + +#[cfg(unix)] +#[test] +fn an_alias_of_an_open_chapter_blocks_all_batch_writes() { + let d = dir("open-alias"); + let first = d.path().join("first.designcraft"); + let real = d.path().join("real.designcraft"); + let alias = d.path().join("alias.designcraft"); + native(&first, 9, false); + native(&real, 5, false); + std::os::unix::fs::symlink("real.designcraft", &alias).unwrap(); + let mut s = book(d.path(), &[&first, &alias]); + s.execute("file.open", &json!({"path":real.to_string_lossy()})).unwrap(); + let first_before = std::fs::read(&first).unwrap(); + let real_before = std::fs::read(&real).unwrap(); + let result = s.execute("book.paginate", &json!({})); + assert!(result.is_err(), "book.paginate accepted alias of open chapter: {result:?}"); + assert_eq!(std::fs::read(&first).unwrap(), first_before); + assert_eq!(std::fs::read(&real).unwrap(), real_before); + let e = result.unwrap_err().to_string(); + assert!(e.contains("alias.designcraft") && e.contains("close"), "{e}"); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); +} +#[test] +fn imported_idml_remains_a_read_only_style_source() { + let d = dir("idml-style-source"); + let source = d.path().join("source.idml"); + let target = d.path().join("target.designcraft"); + std::fs::write(&source, designcraft_idml::export_idml(&document(1, true))).unwrap(); + native(&target, 1, false); + let mut s = book(d.path(), &[&source, &target]); + let before = std::fs::read(&source).unwrap(); + let result = s.execute("book.syncStyles", &json!({})).unwrap(); + assert_eq!(result["synced"], 1); + assert_eq!(std::fs::read(&source).unwrap(), before); + assert!(designcraft_idml::import_idml(&before).is_ok()); + let target = designcraft_format::load(&std::fs::read(target).unwrap()).unwrap(); + assert!(target.styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style")); +} +#[test] +fn imported_idml_still_exports_to_book_pdf_without_source_changes() { + let d = dir("idml-pdf"); + let chapter = d.path().join("chapter.idml"); + idml(&chapter, 1); + let mut s = book(d.path(), &[&chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.exportPdf", &json!({})).unwrap(); + assert_eq!(result["pages"], 2); + assert!(result["base64"].as_str().is_some_and(|s| !s.is_empty())); + assert_eq!(std::fs::read(chapter).unwrap(), before); +} diff --git a/crates/engine/tests/character_style_from_selection.rs b/crates/engine/tests/character_style_from_selection.rs new file mode 100644 index 000000000..0c9d80831 --- /dev/null +++ b/crates/engine/tests/character_style_from_selection.rs @@ -0,0 +1,274 @@ +//! Character Styles panel's existing `fromSelection` command contract. +use designcraft_doc::{CharAttrs, StoryId}; +use designcraft_engine::Session; +use serde_json::{Value, json}; + +fn story(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": text})).unwrap(); + (s, frame["story"].as_u64().unwrap()) +} + +fn select(s: &mut Session, sid: u64, anchor: usize, focus: usize) { + s.execute("text.select", &json!({"story": sid, "anchor": anchor, "focus": focus})).unwrap(); +} + +fn chars(s: &Session, name: &str) -> CharAttrs { + s.doc().unwrap().doc.styles.char_style(name).unwrap().chars.clone() +} + +fn document(s: &Session) -> Value { + serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap() +} + +#[test] +fn character_capture_overlays_style_chain_local_and_explicit_attrs_but_inherits_paragraph() { + let (mut s, sid) = story("alpha\nbeta"); + s.execute("style.paragraph.create", &json!({"name": "Source paragraph", "chars": {"size": 18}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"size": 30, "underline": true}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Base", "chars": {"underline": true, "tracking": 10, "strikethrough": true}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"fontStyle": "Italic"}})).unwrap(); + select(&mut s, sid, 0, 5); + s.execute("style.paragraph.apply", &json!({"name": "Source paragraph"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("type.char", &json!({"tracking": 20, "underline": false})).unwrap(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true, "chars": {"tracking": 7}})).unwrap(); + assert_eq!( + chars(&s, "Captured"), + CharAttrs { font_style: Some("Italic".into()), underline: Some(false), tracking: Some(7.0), strikethrough: Some(true), ..Default::default() } + ); + select(&mut s, sid, 6, 10); + s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + let resolved = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(resolved["chars"]["fontStyle"], "Italic"); + assert_eq!(resolved["chars"]["strikethrough"], true); + assert_eq!(resolved["chars"]["underline"], false); + assert_eq!(resolved["chars"]["tracking"], 7.0); + assert_eq!(resolved["chars"]["size"], 30.0); + s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"size": 34}})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 34.0); + // The captured attributes are a snapshot, independent of future source-style edits. + s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Italic"); +} + +#[test] +fn character_capture_reproducer_is_one_undo_step_and_survives_native_reopen() { + let (mut s, sid) = story("alpha beta"); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + let before = document(&s); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let source = serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Captured"), CharAttrs { underline: Some(true), ..Default::default() }); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(), source); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + assert_eq!(s.doc().unwrap().selection, selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + assert_eq!(s.doc().unwrap().selection, selection); + + // Both commands use the native ZIP format, without a filesystem fixture. + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name": "capture.designcraft", "base64": saved["base64"]})).unwrap(); + assert_eq!(chars(&reopened, "Captured"), chars(&s, "Captured")); + select(&mut reopened, sid, 6, 10); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], false); + let before_apply = document(&reopened); + reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], true); + assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta"); + let after_apply = document(&reopened); + reopened.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&reopened), before_apply); + reopened.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&reopened), after_apply); +} + +#[test] +fn character_capture_matches_paragraph_selection_and_caret_boundary_policy() { + let (mut s, sid) = story("éX"); + select(&mut s, sid, 0, 2); + s.execute("type.char", &json!({"underline": true})).unwrap(); + select(&mut s, sid, 2, 3); + s.execute("type.char", &json!({"underline": false})).unwrap(); + // A selected character takes the run on the right; a caret takes the typing run on the left. + // Reversed and mixed selections start at the lowest byte offset, just like paragraph capture. + for (anchor, focus, underlined) in [(3, 2, false), (2, 2, true), (3, 0, true)] { + select(&mut s, sid, anchor, focus); + let p = s.execute("style.paragraph.create", &json!({"name": "Paragraph", "fromSelection": true})).unwrap(); + let c = s.execute("style.character.create", &json!({"name": "Character", "fromSelection": true})).unwrap(); + let p = s.doc().unwrap().doc.styles.para(p["name"].as_str().unwrap()).unwrap(); + let c = chars(&s, c["name"].as_str().unwrap()); + assert_eq!(c.underline, Some(underlined)); + assert_eq!(c.underline, p.chars.underline); + assert_eq!(c.size, None, "unlike paragraph capture, character capture must not freeze paragraph defaults"); + } +} + +#[test] +fn character_capture_is_opt_in_and_no_text_uses_only_explicit_chars() { + let (mut s, sid) = story("alpha"); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + for options in [json!({"name": "Plain"}), json!({"name": "Plain", "fromSelection": false})] { + let created = s.execute("style.character.create", &options).unwrap(); + assert!(chars(&s, created["name"].as_str().unwrap()).is_empty()); + } + assert!(s.doc().unwrap().doc.styles.char_style("Plain 2").is_some()); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + s.execute("style.character.create", &json!({"name": "No target", "fromSelection": true, "basedOn": "Plain", "chars": {"underline": false}})) + .unwrap(); + assert_eq!(chars(&s, "No target"), CharAttrs { underline: Some(false), ..Default::default() }); + assert_eq!(s.doc().unwrap().doc.styles.char_style("No target").unwrap().based_on.as_deref(), Some("Plain")); + let (mut empty, empty_sid) = story(""); + select(&mut empty, empty_sid, 0, 0); + empty.execute("style.character.create", &json!({"name": "Empty", "fromSelection": true})).unwrap(); + assert!(chars(&empty, "Empty").is_empty()); +} + +#[test] +fn character_capture_retains_deleted_fill_and_reuses_it_after_native_reopen() { + for named_source in [false, true] { + let (mut s, sid) = story("alpha beta gamma"); + s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap(); + select(&mut s, sid, 0, 5); + if named_source { + s.execute("style.character.create", &json!({"name": "Base", "chars": {"fill": "Ink"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Source"})).unwrap(); + } else { + s.execute("type.char", &json!({"fill": "Ink"})).unwrap(); + } + s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap(); + s.doc().unwrap().doc.check().unwrap(); + let before = document(&s); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Captured"), CharAttrs { fill: Some("Ink".into()), ..Default::default() }); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection, selection); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + // Non-fill overrides do not turn an existing captured reference into explicit input. + s.execute("style.character.create", &json!({"name": "Tweaked", "fromSelection": true, "chars": {"underline": false, "tracking": 0}})) + .unwrap(); + assert_eq!(chars(&s, "Tweaked"), CharAttrs { fill: Some("Ink".into()), underline: Some(false), tracking: Some(0.0), ..Default::default() }); + s.execute("style.character.create", &json!({"name": "Paper", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); + assert_eq!(chars(&s, "Paper"), CharAttrs { fill: Some("[Paper]".into()), ..Default::default() }); + s.execute("style.character.create", &json!({"name": "Cleared fill", "fromSelection": true, "chars": {"fill": null}})).unwrap(); + assert!(chars(&s, "Cleared fill").is_empty()); + select(&mut s, sid, 6, 10); + s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + s.doc().unwrap().doc.check().unwrap(); + + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name": "deleted-fill.designcraft", "base64": saved["base64"]})).unwrap(); + reopened.doc().unwrap().doc.check().unwrap(); + for name in ["Captured", "Tweaked", "Paper", "Cleared fill"] { + assert_eq!(chars(&reopened, name), chars(&s, name)); + } + select(&mut reopened, sid, 6, 10); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + select(&mut reopened, sid, 11, 16); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + reopened.execute("style.character.apply", &json!({"name": "Cleared fill"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta gamma"); + } +} + +#[test] +fn character_capture_rejects_explicit_missing_fills_without_losing_redo() { + let (mut s, sid) = story("alpha"); + s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap(); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"fill": "Ink"})).unwrap(); + s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Redo sentinel"})).unwrap(); + let redo_doc = s.doc().unwrap().doc.clone(); + let redo_selection = s.doc().unwrap().selection.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + let before = s.doc().unwrap().clone(); + let journal = s.journal.clone(); + for (capture, fill) in [(true, "Missing"), (true, "Ink"), (false, "Missing")] { + let error = s.execute("style.character.create", &json!({"name": "Invalid", "fromSelection": capture, "chars": {"fill": fill}})).unwrap_err(); + assert!(error.to_string().contains(fill)); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc)); + assert_eq!(s.doc().unwrap().selection, before.selection); + assert_eq!(s.doc().unwrap().revision, before.revision); + assert_eq!(format!("{:?}", s.doc().unwrap().history), format!("{:?}", before.history)); + assert_eq!(s.journal, journal); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &redo_doc)); + assert_eq!(s.doc().unwrap().selection, redo_selection); + assert!(s.doc().unwrap().doc.styles.char_style("Redo sentinel").is_some()); +} + +#[test] +fn character_capture_explicit_null_restores_live_paragraph_inheritance() { + for named_source in [false, true] { + let (mut s, sid) = story("alpha\nbeta"); + select(&mut s, sid, 0, 5); + let source = json!({"tracking": 20, "underline": true, "underlineWeight": 1.5, "fontStyle": "Italic"}); + if named_source { + s.execute("style.character.create", &json!({"name": "Base", "chars": source})).unwrap(); + s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Source"})).unwrap(); + } else { + s.execute("type.char", &source).unwrap(); + } + // Omitting chars still captures values, including a nullable attribute's numeric value. + s.execute("style.character.create", &json!({"name": "Unchanged", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Unchanged").tracking, Some(20.0)); + assert_eq!(chars(&s, "Unchanged").underline_weight, Some(Some(1.5))); + let before = document(&s); + let undo_len = s.doc().unwrap().history.undo.len(); + s.execute( + "style.character.create", + &json!({"name": "Cleared", "fromSelection": true, + "chars": {"tracking": null, "underlineWeight": null, "underline": false, "baselineShift": 0}}), + ) + .unwrap(); + assert_eq!( + chars(&s, "Cleared"), + CharAttrs { font_style: Some("Italic".into()), underline: Some(false), baseline_shift: Some(0.0), ..Default::default() } + ); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"tracking": 30, "underlineWeight": 2}})).unwrap(); + select(&mut s, sid, 6, 10); + s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Cleared"})).unwrap(); + let effective = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(effective["chars"]["tracking"], 30.0); + assert_eq!(effective["chars"]["underlineWeight"], 2.0); + assert_eq!(effective["chars"]["underline"], false); + s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"tracking": 40, "underlineWeight": 3}})).unwrap(); + let effective = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(effective["chars"]["tracking"], 40.0); + assert_eq!(effective["chars"]["underlineWeight"], 3.0); + } +} diff --git a/crates/engine/tests/collection_state_atomicity.rs b/crates/engine/tests/collection_state_atomicity.rs new file mode 100644 index 000000000..b73eefa7e --- /dev/null +++ b/crates/engine/tests/collection_state_atomicity.rs @@ -0,0 +1,87 @@ +#![cfg(not(target_arch = "wasm32"))] + +use designcraft_engine::Session; +use serde_json::json; + +fn library_failure(command: &str) { + let temp = tempfile::tempdir().unwrap(); + let folder = temp.path().join("library"); + std::fs::create_dir(&folder).unwrap(); + let file = folder.join("items.dclib"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap(); + s.execute("library.new", &json!({"path": file})).unwrap(); + s.execute("library.add", &json!({"name": "Keep"})).unwrap(); + let before = serde_json::to_value(&s.library).unwrap(); + let path = s.library.as_ref().unwrap().path.clone(); + let journal = s.journal.clone(); + let original_bytes = std::fs::read(&file).unwrap(); + let kept = temp.path().join("kept-library"); + std::fs::rename(&folder, &kept).unwrap(); + + assert!(s.execute(command, &json!({"name": "New", "index": 0})).is_err()); + + assert_eq!(before, serde_json::to_value(&s.library).unwrap(), "a failed library write must retain its original items"); + assert_eq!(path, s.library.as_ref().unwrap().path); + assert_eq!(journal, s.journal); + assert_eq!(original_bytes, std::fs::read(kept.join("items.dclib")).unwrap()); +} + +#[test] +fn failed_library_add_keeps_existing_items() { + library_failure("library.add"); +} + +#[test] +fn failed_library_remove_keeps_existing_items() { + library_failure("library.remove"); +} + +fn book_failure(command: &str) { + let temp = tempfile::tempdir().unwrap(); + let mut s = Session::new(); + let mut chapters = Vec::new(); + for i in 0..3 { + s.execute("file.new", &json!({})).unwrap(); + let path = temp.path().join(format!("chapter-{i}.designcraft")); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + chapters.push(path); + } + let folder = temp.path().join("book"); + std::fs::create_dir(&folder).unwrap(); + let file = folder.join("chapters.dcbook"); + s.execute("book.new", &json!({"path": file})).unwrap(); + s.execute("book.add", &json!({"path": chapters[0]})).unwrap(); + s.execute("book.add", &json!({"path": chapters[1]})).unwrap(); + s.execute("book.styleSource", &json!({"index": 1})).unwrap(); + let before = serde_json::to_value(&s.book).unwrap(); + let path = s.book.as_ref().unwrap().path.clone(); + let journal = s.journal.clone(); + let original_bytes = std::fs::read(&file).unwrap(); + let kept = temp.path().join("kept-book"); + std::fs::rename(&folder, &kept).unwrap(); + let params = if command == "book.add" { json!({"path": chapters[2]}) } else { json!({"index": 0}) }; + + assert!(s.execute(command, ¶ms).is_err()); + + assert_eq!(before, serde_json::to_value(&s.book).unwrap(), "a failed book write must retain its original chapter list and style source"); + assert_eq!(path, s.book.as_ref().unwrap().path); + assert_eq!(journal, s.journal); + assert_eq!(original_bytes, std::fs::read(kept.join("chapters.dcbook")).unwrap()); +} + +#[test] +fn failed_book_add_keeps_existing_chapters() { + book_failure("book.add"); +} + +#[test] +fn failed_book_remove_keeps_existing_chapters() { + book_failure("book.remove"); +} + +#[test] +fn failed_book_style_source_keeps_existing_source() { + book_failure("book.styleSource"); +} diff --git a/crates/engine/tests/distribute_locks.rs b/crates/engine/tests/distribute_locks.rs new file mode 100644 index 000000000..4935e9946 --- /dev/null +++ b/crates/engine/tests/distribute_locks.rs @@ -0,0 +1,128 @@ +//! Distribution preserves explicit item locks without changing reference/spacing calculations. +use std::sync::Arc; + +use designcraft_doc::ItemId; +use designcraft_engine::Session; +use designcraft_geom::Affine; +use serde_json::{Value, json}; + +const STARTS: [f64; 4] = [10.0, 70.0, 150.0, 310.0]; +const SIZES: [f64; 4] = [20.0, 30.0, 40.0, 50.0]; +// An original, synthetic 1x1 RGBA PNG; used by both a locked and an unlocked frame. +const PIXEL: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGPYYqPxHwAE2AIYBVnQbwAAAABJRU5ErkJggg=="; + +fn fixture(horizontal: bool, locks: [bool; 4]) -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let mut ids = Vec::new(); + for (i, (&start, &size)) in STARTS.iter().zip(&SIZES).enumerate() { + let cross = 40.0 + i as f64 * 35.0; + let rect = if horizontal { [start, cross, start + size, cross + 25.0] } else { [cross, start, cross + 25.0, start + size] }; + let graphic = i == 1 || i == 2; + let created = + s.execute("frame.create", &json!({"rect": rect, "content": if graphic {"unassigned"} else {"text"}, "text": "Keep this text"})).unwrap(); + let id = created["id"].as_u64().unwrap(); + ids.push(id); + if graphic { + s.execute("file.place", &json!({"frame": id, "base64": PIXEL, "name": "pixel.png"})).unwrap(); + } + } + // An unselected control must retain all geometry and content too. + s.execute("frame.create", &json!({"rect": [400, 400, 450, 450], "content": "text", "text": "Outside selection"})).unwrap(); + s.execute("object.fill", &json!({"ids": ids, "swatch": "[Black]", "tint": 0.5})).unwrap(); + let locked: Vec = ids.iter().zip(locks).filter_map(|(&id, lock)| lock.then_some(id)).collect(); + if !locked.is_empty() { + s.execute("object.lock", &json!({"ids": locked})).unwrap(); + } + // Layers selection may include locked items. Deliberately avoid geometric order. + s.execute("selection.set", &json!({"ids": [ids[3], ids[1], ids[0], ids[2]]})).unwrap(); + assert!(!s.doc().unwrap().doc.assets.is_empty()); + s.doc().unwrap().doc.check().unwrap(); + (s, ids) +} + +fn modes() -> [(Value, [f64; 4]); 3] { + [ + // Endpoint centers 20 and 335 give a center step of 105. + (json!({"by": "centers"}), [10.0, 110.0, 210.0, 310.0]), + // Outer span 350 minus total widths 140 gives three gaps of 70. + (json!({"by": "spacing"}), [10.0, 100.0, 200.0, 310.0]), + // An explicit spacing value selects fixed-gap mode without requiring `by`. + (json!({"spacing": 15}), [10.0, 45.0, 90.0, 145.0]), + ] +} + +fn check_distribution(horizontal: bool, mut params: Value, positions: [f64; 4], locks: [bool; 4], explicit_ids: bool) { + let (mut s, ids) = fixture(horizontal, locks); + params["axis"] = json!(if horizontal { "horizontal" } else { "vertical" }); + if explicit_ids { + params["ids"] = json!([ids[2], ids[0], ids[3], ids[1]]); + } + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let mut expected = (*before).clone(); + for (i, &id) in ids.iter().enumerate() { + if !locks[i] { + let delta = positions[i] - STARTS[i]; + let translation = if horizontal { (delta, 0.0) } else { (0.0, delta) }; + let item = expected.item_mut(ItemId(id)).unwrap(); + item.xf = Affine::translate(translation) * item.xf; + } + } + s.execute("object.distribute", ¶ms).unwrap(); + let after = s.doc().unwrap().doc.clone(); + for (i, &id) in ids.iter().enumerate() { + let item = after.item(ItemId(id)).unwrap(); + let b = item.bounds(); + let start = if horizontal { b.x0 } else { b.y0 }; + assert_eq!(start, if locks[i] { STARTS[i] } else { positions[i] }, "item {i}: {params}, locks={locks:?}"); + if locks[i] { + assert_eq!(item, before.item(ItemId(id)).unwrap(), "locked item's transform, path, content and flags are unchanged"); + } + } + // Only the specified translations may change: all paths, sizes, other-axis coordinates, + // paint, content, stories, layer membership, item order and embedded asset bytes are exact. + assert_eq!(*after, expected, "{params}, locks={locks:?}"); + assert_eq!(s.doc().unwrap().selection, selection); + after.check().unwrap(); + if expected != *before { + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before)); + assert_eq!(s.doc().unwrap().selection, selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &after)); + assert_eq!(s.doc().unwrap().selection, selection); + } +} + +#[test] +fn distribute_preserves_locked_items_and_moves_unlocked_controls() { + for horizontal in [true, false] { + for (params, positions) in modes() { + // Locked interior object still participates in the numeric reference calculation. + check_distribution(horizontal, params.clone(), positions, [false, true, false, false], false); + // Locked endpoints and an interior object; only the unlocked interior control moves. + check_distribution(horizontal, params, positions, [true, true, false, true], true); + } + } +} + +#[test] +fn distribute_unlocked_spacing_modes_are_unchanged() { + for horizontal in [true, false] { + for (params, positions) in modes() { + check_distribution(horizontal, params, positions, [false; 4], true); + } + } +} + +#[test] +fn distribute_all_locked_leaves_document_and_selection_unchanged() { + for horizontal in [true, false] { + for (params, positions) in modes() { + check_distribution(horizontal, params, positions, [true; 4], false); + } + } +} diff --git a/crates/engine/tests/preflight_resolution.rs b/crates/engine/tests/preflight_resolution.rs new file mode 100644 index 000000000..f4562c569 --- /dev/null +++ b/crates/engine/tests/preflight_resolution.rs @@ -0,0 +1,217 @@ +//! Effective raster resolution follows image, frame and ancestor transforms in spread space. +use std::sync::Arc; + +use designcraft_doc::{Content, Intent, ItemId}; +use designcraft_engine::{Session, cmd::base64_encode}; +use designcraft_geom::Affine; +use serde_json::{Value, json}; + +fn place(s: &mut Session, name: &str, bytes: &[u8]) -> ItemId { + s.execute("selection.set", &json!({"ids": []})).unwrap(); + let placed = s.execute("file.place", &json!({"base64": base64_encode(bytes), "name": name, "x": 0, "y": 0, "width": 144})).unwrap(); + ItemId(placed["id"].as_u64().unwrap()) +} + +fn raster() -> (Session, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 1000, "height": 1000, "facingPages": false})).unwrap(); + // 600 × 300 pixels at 144 × 72 points: 300 ppi in both directions. + let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png(); + let id = place(&mut s, "photo.png", &png); + assert_eq!(s.doc().unwrap().doc.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0)); + (s, id) +} + +fn facing_raster() -> (Session, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "pages": 3, "facingPages": true})).unwrap(); + // Page 0 is the cover; spread 1 contains absolute pages 1 (left) and 2 (right). + let d = &s.doc().unwrap().doc; + assert_eq!(d.spreads.len(), 2); + assert_eq!(d.spreads[0].pages.len(), 1); + assert_eq!(d.spreads[1].pages.len(), 2); + assert_eq!(d.first_page_of_spread(1), 1); + assert_eq!(d.spreads[1].pages[0].x, 0.0); + assert_eq!(d.spreads[1].pages[1].x, 612.0); + let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png(); + let placed = s + .execute( + "file.place", + &json!({ + "base64": base64_encode(&png), "name": "photo.png", "spread": 1, + "x": 0, "y": 0, "width": 144 + }), + ) + .unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + assert_eq!(d.find(id).unwrap().spread, designcraft_doc::SpreadRef::Doc(1)); + assert_eq!(d.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0)); + (s, id) +} + +fn transform(s: &mut Session, id: ItemId, frame: Affine, image: Affine) { + s.edit(|d, _| { + let it = d.item_mut(id).unwrap(); + it.xf = frame; + let Content::Graphic(g) = &mut it.content else { panic!("graphic fixture") }; + g.xf = image * g.xf; + Ok(()) + }) + .unwrap(); +} + +fn group(s: &mut Session, child: ItemId, xf: Affine) -> ItemId { + let spread = s.doc().unwrap().doc.find(child).unwrap().spread; + let marker = s.execute("frame.create", &json!({"spread": spread, "rect": [0, 0, 1, 1]})).unwrap()["id"].clone(); + let group = s.execute("object.group", &json!({"ids": [child.0, marker]})).unwrap(); + let id = ItemId(group["id"].as_u64().unwrap()); + s.edit(|d, _| { + d.item_mut(id).unwrap().xf = xf; + Ok(()) + }) + .unwrap(); + id +} + +fn resolutions(s: &mut Session, params: Value) -> Vec { + let before = s.doc().unwrap(); + let doc = before.doc.clone(); + let selection = before.selection.clone(); + let state = (before.revision, before.history.undo.len(), before.history.redo.len(), s.journal.len()); + let result = s.execute("preflight.run", ¶ms).unwrap(); + let after = s.doc().unwrap(); + assert!(Arc::ptr_eq(&doc, &after.doc), "preflight must leave the document and assets untouched"); + assert_eq!(selection, after.selection, "preflight must leave the selection untouched"); + assert_eq!(state, (after.revision, after.history.undo.len(), after.history.redo.len(), s.journal.len())); + result["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "lowResolution").cloned().collect() +} + +fn warning(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32) { + warning_on_page(s, id, params, ppi, threshold, 0); +} + +fn warning_on_page(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32, page: usize) { + assert_eq!( + resolutions(s, params), + vec![json!({ + "severity": "warning", "kind": "lowResolution", "item": id.0, "page": page, + "message": format!("photo.png: effective {ppi} ppi (< {threshold})") + })] + ); +} + +#[test] +fn either_raster_axis_can_fail_after_image_or_frame_scaling() { + for scale in [Affine::scale_non_uniform(4.0, 1.0), Affine::scale_non_uniform(1.0, 4.0)] { + for image_only in [false, true] { + let (mut s, id) = raster(); + let (frame, image) = if image_only { (Affine::IDENTITY, scale) } else { (scale, Affine::IDENTITY) }; + transform(&mut s, id, frame, image); + // One axis remains 300 ppi, the other is 75 ppi: report the worse axis once. + warning(&mut s, id, json!({}), 75, 150); + } + } +} + +#[test] +fn nested_groups_accumulate_scale_and_keep_image_frame_parent_order() { + let (mut s, id) = raster(); + let inner = group(&mut s, id, Affine::scale(2.0)); + group(&mut s, inner, Affine::scale(2.0)); + warning(&mut s, id, json!({}), 75, 150); + + let (mut s, id) = raster(); + // Double image X, then turn the frame 90 degrees, then triple spread X. + // The image axes become 288 and 216 pt, giving 150 and 100 ppi respectively. + transform(&mut s, id, Affine::new([0.0, 1.0, -1.0, 0.0, 400.0, 20.0]), Affine::scale_non_uniform(2.0, 1.0)); + group(&mut s, id, Affine::scale_non_uniform(3.0, 1.0)); + warning(&mut s, id, json!({}), 100, 150); +} + +#[test] +fn translated_scaled_nested_raster_reports_right_facing_page() { + let (mut s, id) = facing_raster(); + let inner = group(&mut s, id, Affine::scale(2.0)); + group(&mut s, inner, Affine::translate((640.0, 0.0)) * Affine::scale(2.0)); + // Local frame center x=72 is on the left page. Ancestors move its bounds to + // x=640..1216 on the right page, while scaling 300 ppi down to 75 ppi. + warning_on_page(&mut s, id, json!({}), 75, 150, 2); +} + +#[test] +fn translating_group_changes_warning_page_without_changing_resolution() { + let (mut s, id) = facing_raster(); + // A nonidentity frame transform also detects applying the frame transform twice. + transform(&mut s, id, Affine::translate((-240.0, 0.0)), Affine::IDENTITY); + warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 1); + group(&mut s, id, Affine::translate((880.0, 0.0))); + // Translation moves the center to x=712, but does not change either image axis. + assert!(resolutions(&mut s, json!({})).is_empty()); + warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 2); +} + +#[test] +fn rotation_translation_and_reflection_do_not_change_resolution() { + for xf in [ + Affine::translate((250.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4), + Affine::translate((250.0, 300.0)) * Affine::scale_non_uniform(-1.0, 1.0), + ] { + let (mut s, id) = raster(); + transform(&mut s, id, xf, Affine::IDENTITY); + // Measuring the rotated bounding box instead would incorrectly warn here. + assert!(resolutions(&mut s, json!({"minPpi": 250})).is_empty()); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + } +} + +#[test] +fn cropping_keeps_resolution_and_fitting_changes_it_with_content_scale() { + let (mut s, id) = raster(); + s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 144, 72], "to": [0, 0, 36, 18]})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 36, 18], "to": [0, 0, 144, 216]})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitProportionally"})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + // Stretching just Y to 216 pt leaves X at 300 ppi but reduces Y to 100 ppi. + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitContentToFrame"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); + // Proportional fill is 432 × 216 pt: 100 ppi in both directions. + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fillProportionally"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); + s.execute("object.fit", &json!({"ids": [id.0], "mode": "centerContent"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); +} + +#[test] +fn threshold_is_strict_and_resolution_remains_print_only() { + let (mut s, id) = raster(); + transform(&mut s, id, Affine::scale_non_uniform(1.0, 2.0), Affine::IDENTITY); + assert!(resolutions(&mut s, json!({})).is_empty(), "150 ppi meets the default threshold"); + assert!(resolutions(&mut s, json!({"minPpi": 149})).is_empty()); + warning(&mut s, id, json!({"minPpi": 151}), 150, 151); + for intent in [Intent::Web, Intent::Mobile] { + s.edit(|d, _| { + d.settings.intent = intent; + Ok(()) + }) + .unwrap(); + assert!(resolutions(&mut s, json!({"minPpi": 151})).is_empty()); + } +} + +#[test] +fn transformed_vector_previews_are_excluded_beside_rasters() { + let (mut s, id) = raster(); + let svg = br#""#; + let vector = place(&mut s, "vector.svg", svg); + let parent = s.execute("object.group", &json!({"ids": [id.0, vector.0]})).unwrap()["id"].as_u64().unwrap(); + s.edit(|d, _| { + d.item_mut(ItemId(parent)).unwrap().xf = Affine::scale_non_uniform(1.0, 4.0); + Ok(()) + }) + .unwrap(); + warning(&mut s, id, json!({}), 75, 150); +} diff --git a/crates/engine/tests/primary_frame_columns.rs b/crates/engine/tests/primary_frame_columns.rs new file mode 100644 index 000000000..1a08a7081 --- /dev/null +++ b/crates/engine/tests/primary_frame_columns.rs @@ -0,0 +1,34 @@ +//! Primary text frames retain all columns supported by the new-document command. +use designcraft_doc::Document; +use designcraft_engine::Session; +use serde_json::json; + +fn assert_primary_columns(doc: &Document, expected: u32) { + let mut frames = 0; + for story in doc.stories.values() { + for spec in designcraft_compose::frame_specs(doc, story.id) { + assert_eq!(spec.opts.columns, expected); + let columns = spec.columns(); + assert_eq!(columns.len(), expected as usize); + assert!(columns.iter().all(|r| r.width() > 0. && [r.x0, r.x1, r.y0, r.y1].into_iter().all(f64::is_finite))); + frames += 1; + } + } + assert!(frames > 0, "the primary text frame must exist"); +} + +#[test] +fn primary_columns_survive_composition_native_reopen_and_idml_round_trip() { + for columns in [41, 216] { + let mut s = Session::new(); + s.execute("file.new", &json!({"columns":columns,"gutter":0,"primaryTextFrame":true})).unwrap(); + assert_primary_columns(&s.doc().unwrap().doc, columns); + let bytes = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name":"primary-columns.designcraft","base64":bytes["base64"]})).unwrap(); + assert_primary_columns(&reopened.doc().unwrap().doc, columns); + let idml = designcraft_idml::export_idml(&reopened.doc().unwrap().doc); + let imported = designcraft_idml::import_idml(&idml).unwrap(); + assert_primary_columns(&imported, columns); + } +} diff --git a/crates/engine/tests/save_state_atomicity.rs b/crates/engine/tests/save_state_atomicity.rs new file mode 100644 index 000000000..326d83be8 --- /dev/null +++ b/crates/engine/tests/save_state_atomicity.rs @@ -0,0 +1,68 @@ +#![cfg(not(target_arch = "wasm32"))] + +use std::path::Path; +use std::sync::Arc; + +use designcraft_engine::Session; +use serde_json::json; + +fn linked_document(root: &Path) -> (Session, std::path::PathBuf) { + let source = root.join("source/contacts.csv"); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write(&source, "Name\nAda\n").unwrap(); + let original = root.join("original/template.designcraft"); + std::fs::create_dir_all(original.parent().unwrap()).unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("data.source.select", &json!({"path": source})).unwrap(); + session.execute("file.saveAs", &json!({"path": original})).unwrap(); + (session, original) +} + +#[test] +fn failed_save_as_preserves_document_saved_state_history_and_original_file() { + let temp = tempfile::tempdir().unwrap(); + let (mut session, original) = linked_document(temp.path()); + session.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap(); + session.execute("edit.undo", &json!({})).unwrap(); + let before = session.doc().unwrap().clone(); + let journal = session.journal.clone(); + let original_bytes = std::fs::read(&original).unwrap(); + let destination = temp.path().join("missing/elsewhere/template.designcraft"); + + assert!(session.execute("file.saveAs", &json!({"path": destination})).is_err()); + + let after = session.doc().unwrap(); + assert!(Arc::ptr_eq(&before.doc, &after.doc), "failed Save As must not change data-source paths"); + assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc)); + assert_eq!(before.path, after.path); + assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty())); + assert_eq!(before.doc.data_merge, after.doc.data_merge); + assert_eq!(before.history.undo.len(), after.history.undo.len()); + assert_eq!(before.history.redo.len(), after.history.redo.len()); + assert_eq!(serde_json::to_value(&before.selection).unwrap(), serde_json::to_value(&after.selection).unwrap()); + assert_eq!(journal, session.journal); + assert_eq!(original_bytes, std::fs::read(original).unwrap()); + assert!(!destination.exists()); +} + +#[test] +fn save_a_copy_rebases_its_data_source_without_changing_the_open_document() { + let temp = tempfile::tempdir().unwrap(); + let (mut session, original) = linked_document(temp.path()); + let before = session.doc().unwrap().clone(); + let original_bytes = std::fs::read(&original).unwrap(); + let destination = temp.path().join("copies/nested/template.designcraft"); + std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); + + session.execute("file.saveACopy", &json!({"path": destination})).unwrap(); + + let copy = designcraft_format::load(&std::fs::read(destination).unwrap()).unwrap(); + assert_eq!(copy.data_merge.sources[0].relative_path.as_deref(), Some("../../source/contacts.csv")); + let after = session.doc().unwrap(); + assert!(Arc::ptr_eq(&before.doc, &after.doc)); + assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc)); + assert_eq!(before.path, after.path); + assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty())); + assert_eq!(original_bytes, std::fs::read(original).unwrap()); +} diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/text_decorations.rs index cd3388f76..c49c1d2cb 100644 --- a/crates/engine/tests/text_decorations.rs +++ b/crates/engine/tests/text_decorations.rs @@ -163,3 +163,42 @@ fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() { } } } + +/// An underline is one bar per stretch of text: a bar per glyph left lighter seams between the +/// letters where the anti-aliased edges met (at fractional positions). +#[test] +fn an_underline_has_no_seams_between_letters() { + let mut d = Document::new(&NewDocument { width: 400.0, height: 120.0, facing_pages: false, ..Default::default() }); + let chars = CharAttrs { + font_family: Some("Source Sans 3".into()), + size: Some(48.0), + fill: Some("[Black]".into()), + underline: Some(true), + underline_weight: Some(Some(6.0)), + ..Default::default() + }; + d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 390.0, 110.0), d.default_layer(), "FERRUM", ParaFormat { chars, ..Default::default() }) + .unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() }; + // A fractional scale puts the letters' edges between pixels. + let (w, h) = (543u32, 163u32); + let img = renderer.render(&d, &Cache::new(), &placed, w, h, Affine::scale(1.3579), &options); + let px = |x: u32, y: u32| { + let i = ((y * w + x) * 4) as usize; + [img.pixels[i], img.pixels[i + 1], img.pixels[i + 2], img.pixels[i + 3]] + }; + // The underline's middle row: the row with the longest run of dark pixels ([Black] shows as + // about 52 grey), then nothing lighter than its darkest pixel along it. + let row = (0..h).max_by_key(|&y| (0..w).filter(|&x| px(x, y)[0] < 110).count()).unwrap(); + let dark: Vec = (0..w).filter(|&x| px(x, row)[0] < 110).collect(); + let (x0, x1) = (dark[0], *dark.last().unwrap()); + assert!(x1 - x0 > 150, "the underline spans the word: {x0}..{x1}"); + // The bar's own anti-aliased ends aside. + let inside = x0 + 2..=x1 - 2; + let ink = inside.clone().map(|x| px(x, row)[0]).min().unwrap(); + let light: Vec<(u32, u8)> = inside.map(|x| (x, px(x, row)[0])).filter(|&(_, v)| v > ink + 12).collect(); + assert!(light.is_empty(), "seams in the underline at {light:?}"); +} diff --git a/crates/engine/tests/text_thread_regressions.rs b/crates/engine/tests/text_thread_regressions.rs new file mode 100644 index 000000000..2bca1b06d --- /dev/null +++ b/crates/engine/tests/text_thread_regressions.rs @@ -0,0 +1,277 @@ +use designcraft_doc::{ParaFormat, Story, StoryId}; +use designcraft_engine::Session; +use serde_json::{Value, json}; +use std::sync::Arc; +fn setup() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s +} +fn frame(s: &mut Session, text: &str) -> (u64, u64) { + let r = s.execute("frame.create", &json!({"rect":[72,72,300,300],"content":"text","text":text,"caret":false})).unwrap(); + (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()) +} +fn snapshot(s: &Session) -> Value { + let d = s.doc().unwrap(); + json!({"doc":d.doc,"selection":d.selection,"revision":d.revision,"dirty":d.is_dirty(),"undo":d.history.undo.len(),"redo":d.history.redo.len(),"journal":s.journal}) +} +#[test] +fn rejected_thread_is_atomic_with_new_frame_and_redo() { + let mut s = setup(); + let (a, _) = frame(&mut s, "hello"); + frame(&mut s, "undo"); + s.execute("edit.undo", &json!({})).unwrap(); + let before = snapshot(&s); + assert!(s.execute("text.thread", &json!({"from":999999,"rect":[0,0,100,100]})).is_err()); + assert_eq!(snapshot(&s), before); + assert!(s.execute("text.thread", &json!({"from":a,"rect":[0,0,100,100],"spread":999})).is_err()); + assert_eq!(snapshot(&s), before); +} +#[test] +fn locked_frame_cannot_be_unthreaded() { + let mut s = setup(); + let (a, _) = frame(&mut s, "hello"); + let (b, _) = frame(&mut s, ""); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + s.execute("object.lock", &json!({"ids":[a]})).unwrap(); + let before = snapshot(&s); + let r = s.execute("text.unthread", &json!({"frame":a})); + assert!(r.is_err(), "unthread accepted locked frame: {r:?}"); + assert_eq!(snapshot(&s), before); +} +#[test] +fn locked_frame_cannot_be_removed_from_thread() { + let mut s = setup(); + let (a, _) = frame(&mut s, "hello"); + let (b, _) = frame(&mut s, ""); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + s.execute("object.lock", &json!({"ids":[a]})).unwrap(); + let before = snapshot(&s); + let r = s.execute("text.removeFromThread", &json!({"frame":a})); + assert!(r.is_err(), "remove accepted locked frame: {r:?}"); + assert_eq!(snapshot(&s), before); +} +#[test] +fn rejected_selection_preserves_pending_typing() { + let mut s = setup(); + let (_, sid) = frame(&mut s, "hello"); + s.execute("text.select", &json!({"story":sid,"anchor":3})).unwrap(); + s.execute("type.char", &json!({"attrs":{"size":30}})).unwrap(); + let before = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert!(s.execute("text.select", &json!({"story":999999,"anchor":0})).is_err()); + let after = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(after, before, "rejected selection reset pending typing"); +} +#[test] +fn append_max_id_preserves_both_tables() { + use designcraft_doc::table::Table; + let mut a = Story::with_text(StoryId(1), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default()); + let mut b = Story::with_text(StoryId(2), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default()); + let mut ta = Table::new(u64::MAX, 1, 1, 0, 0, 100.); + ta.cells[0].text = Story::with_text(StoryId(0), "ORIGINAL", ParaFormat::default()); + a.tables.insert(u64::MAX, Arc::new(ta)); + let mut tb = Table::new(1, 1, 1, 0, 0, 100.); + tb.cells[0].text = Story::with_text(StoryId(0), "INCOMING", ParaFormat::default()); + b.tables.insert(1, Arc::new(tb)); + a.paras[0].table = Some(u64::MAX); + b.paras[0].table = Some(1); + a.check().unwrap(); + b.check().unwrap(); + a.append_story(b); + let contents: Vec<_> = a.tables.values().map(|t| t.cells[0].text.text.clone()).collect(); + assert!(contents.contains(&"ORIGINAL".into()) && contents.contains(&"INCOMING".into()), "table loss: {contents:?}"); +} +#[test] +fn idml_dropcap_detail_preserves_inherited_other_bit() { + use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, build::NewDocument}; + let mut d = Document::new(&NewDocument::default()); + let parent = ParagraphStyle { + name: "Parent".into(), + based_on: None, + next_style: None, + para: ParaAttrs { drop_cap_align_left: Some(false), drop_cap_scale_descenders: Some(true), ..Default::default() }, + chars: Default::default(), + shortcut: String::new(), + }; + let child = ParagraphStyle { + name: "Child".into(), + based_on: Some("Parent".into()), + next_style: None, + para: ParaAttrs { drop_cap_scale_descenders: Some(false), ..Default::default() }, + chars: Default::default(), + shortcut: String::new(), + }; + d.styles_mut().paragraph.extend([parent, child]); + let before = d.styles.resolve_para_style("Child").0; + let bytes = designcraft_idml::export_idml(&d); + let back = designcraft_idml::import_idml(&bytes).unwrap(); + let after = back.styles.resolve_para_style("Child").0; + assert_eq!( + (after.drop_cap_align_left, after.drop_cap_scale_descenders), + (before.drop_cap_align_left, before.drop_cap_scale_descenders), + "IDML export resets inherited align-left bit" + ); +} +#[test] +fn threading_preserves_incoming_text_hyperlink() { + let mut s = setup(); + let (a, sa) = frame(&mut s, "First"); + let (b, sb) = frame(&mut s, "Second"); + s.execute("text.select", &json!({"story":sb,"anchor":0,"focus":6})).unwrap(); + s.execute("hyperlink.create", &json!({"url":"https://example.com"})).unwrap(); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = designcraft_format::load(&designcraft_format::save(d).unwrap()).unwrap(); + match &back.hyperlinks[0].source { + designcraft_doc::HyperlinkSource::Text { story, start, end } => { + assert_eq!(story.0, sa, "hyperlink points to deleted incoming story"); + assert_eq!((*start, *end), (6, 12)); + } + _ => panic!("wrong source"), + }; +} +#[test] +fn thread_cannot_destroy_body_by_joining_generated_endnotes() { + let mut s = setup(); + let (body, sid) = frame(&mut s, "KEEP THIS BODY"); + s.execute("text.select", &json!({"story":sid,"anchor":14})).unwrap(); + let e = s.execute("endnote.insert", &json!({"text":"a note"})).unwrap(); + let ef = e["frame"].as_u64().unwrap(); + let before = snapshot(&s); + let r = s.execute("text.thread", &json!({"from":ef,"to":body})); + assert!( + r.is_err(), + "thread into generated endnotes accepted: {r:?}; stories: {:?}", + s.doc().unwrap().doc.stories.values().map(|s| &s.text).collect::>() + ); + assert_eq!(snapshot(&s), before); +} +#[test] +fn generated_endnotes_accept_empty_extensions_but_reject_reverse_body_merge() { + let mut s = setup(); + let (body, sid) = frame(&mut s, "Body"); + s.execute("text.select", &json!({"story":sid,"anchor":4})).unwrap(); + let r = s.execute("endnote.insert", &json!({"text":"note"})).unwrap(); + let ef = r["frame"].as_u64().unwrap(); + let esid = s.doc().unwrap().doc.endnote_story.unwrap(); + let before = snapshot(&s); + assert!(s.execute("text.thread", &json!({"from":body,"to":ef})).is_err()); + assert_eq!(snapshot(&s), before); + s.execute("text.thread", &json!({"from":ef,"rect":[72,72,300,300]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid)); + s.execute("text.thread", &json!({"to":ef,"rect":[72,72,300,300]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid)); + assert_eq!(s.doc().unwrap().doc.story(esid).unwrap().frames.len(), 3); + assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text.starts_with("Body")); + s.doc().unwrap().doc.check().unwrap(); +} +#[test] +fn threading_moves_data_placeholders_and_primary_story_with_text() { + use designcraft_doc::{Placeholder, PlaceholderAnchor, PlaceholderRole}; + let mut s = setup(); + let (a, sa) = frame(&mut s, "First"); + let (b, sb) = frame(&mut s, "<>"); + s.edit(|d, _| { + d.settings.primary_story = Some(StoryId(sb)); + d.data_merge.placeholders.push(Placeholder { + id: 123, + source_id: 0, + field: "name".into(), + role: PlaceholderRole::Text, + anchor: PlaceholderAnchor::Text { story: StoryId(sb), start: 0, end: 8 }, + }); + Ok(()) + }) + .unwrap(); + let before = s.doc().unwrap().doc.clone(); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.primary_story, Some(StoryId(sa))); + assert_eq!(d.data_merge.placeholders.len(), 1); + assert_eq!(d.data_merge.placeholders[0].anchor, PlaceholderAnchor::Text { story: StoryId(sa), start: 6, end: 14 }); + let after = d.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); +} +#[test] +fn append_max_ids_preserves_addressable_footnotes_and_endnotes() { + let mut a = Story::with_text(StoryId(1), "A", ParaFormat::default()); + let mut b = Story::with_text(StoryId(2), "B", ParaFormat::default()); + a.insert_note(1, "original footnote", ParaFormat::default()); + b.insert_note(1, "incoming footnote", ParaFormat::default()); + Arc::make_mut(&mut a.notes[0]).id = u64::MAX; + a.insert_endnote(1, "original endnote", ParaFormat::default()); + b.insert_endnote(1, "incoming endnote", ParaFormat::default()); + Arc::make_mut(&mut a.endnotes[0]).id = u64::MAX; + a.check().unwrap(); + b.check().unwrap(); + a.append_story(b); + a.check().unwrap(); + assert_ne!(a.notes[0].id, a.notes[1].id); + assert_ne!(a.endnotes[0].id, a.endnotes[1].id); + assert_eq!(a.notes[0].text.text, "original footnote"); + assert_eq!(a.endnotes[1].text.text, "incoming endnote"); +} +#[test] +fn locked_layer_and_neighbour_reject_unthread_atomically() { + for lock_layer in [false, true] { + let mut s = setup(); + let (a, _) = frame(&mut s, "A"); + let (b, _) = frame(&mut s, "B"); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + if lock_layer { + let lid = s.doc().unwrap().active_layer.0; + s.execute("layer.set", &json!({"id":lid,"locked":true})).unwrap(); + } else { + s.execute("object.lock", &json!({"ids":[b]})).unwrap(); + } + let before = snapshot(&s); + assert!(s.execute("text.unthread", &json!({"frame":a})).is_err()); + assert_eq!(snapshot(&s), before); + } +} +#[test] +fn idml_partial_dropcap_bits_resolve_both_style_and_paragraph_inheritance() { + use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, SpreadRef, build::NewDocument}; + use designcraft_geom::Rect; + for parent_align in [false, true] { + for parent_scale in [false, true] { + for local_align in [false, true] { + let mut d = Document::new(&NewDocument::default()); + d.styles_mut().paragraph.push(ParagraphStyle { + name: "Parent".into(), + based_on: None, + next_style: None, + para: ParaAttrs { drop_cap_align_left: Some(parent_align), drop_cap_scale_descenders: Some(parent_scale), ..Default::default() }, + chars: Default::default(), + shortcut: String::new(), + }); + let layer = d.default_layer(); + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(10., 10., 200., 200.), + layer, + "Body", + ParaFormat { + style: "Parent".into(), + para: ParaAttrs { drop_cap_align_left: Some(local_align), ..Default::default() }, + ..Default::default() + }, + ) + .unwrap(); + let bytes = designcraft_idml::export_idml(&d); + let back = designcraft_idml::import_idml(&bytes).unwrap(); + let st = back.stories.values().find(|s| s.text == "Body").unwrap(); + let actual = back.styles.resolve_para(&st.paras[0]).0; + let expected = d.styles.resolve_para(&d.story(sid).unwrap().paras[0]).0; + assert_eq!( + (actual.drop_cap_align_left, actual.drop_cap_scale_descenders), + (expected.drop_cap_align_left, expected.drop_cap_scale_descenders) + ); + } + } + } +} diff --git a/crates/engine/tests/thread_color_safety.rs b/crates/engine/tests/thread_color_safety.rs new file mode 100644 index 000000000..501df92b9 --- /dev/null +++ b/crates/engine/tests/thread_color_safety.rs @@ -0,0 +1,223 @@ +//! Linked-story threading and text-color transaction safety. +use designcraft_doc::{ParaFormat, Story, StoryId, table::Table}; +use designcraft_engine::Session; +use serde_json::{Value, json}; +use std::sync::Arc; + +fn setup() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s +} +fn frame(s: &mut Session, text: &str) -> (u64, StoryId) { + let f = s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":text,"caret":false})).unwrap(); + (f["id"].as_u64().unwrap(), StoryId(f["story"].as_u64().unwrap())) +} +fn child(s: &mut Session, parent: StoryId) -> (u64, StoryId) { + let f = s.execute("story.placeAndLink", &json!({"story":parent.0,"rect":[36,150,200,100]})).unwrap(); + (f["id"].as_u64().unwrap(), StoryId(f["story"].as_u64().unwrap())) +} +fn state(s: &Session) -> Value { + let d = s.doc().unwrap(); + json!({"doc":d.doc,"selection":d.selection,"revision":d.revision,"dirty":d.is_dirty(),"undo":format!("{:?}",d.history),"journal":s.journal}) +} +fn link(s: &mut Session, sid: StoryId) -> Value { + s.execute("story.links", &json!({})).unwrap().as_array().unwrap().iter().find(|l| l["story"] == sid.0).unwrap().clone() +} + +#[test] +fn safety_linked_parent_follows_merge_and_updates_after_native_reopen() { + let mut s = setup(); + let (first, survivor) = frame(&mut s, "PREFIX"); + let (incoming, parent) = frame(&mut s, "PARENT"); + let (_, linked) = child(&mut s, parent); + // A coincident old parent revision must not hide the change of parent identity/content. + let future_rev = s.doc().unwrap().doc.story(survivor).unwrap().rev + 2; + s.edit(|d, _| { + d.story_mut(linked).unwrap().link = Some((parent, future_rev)); + Ok(()) + }) + .unwrap(); + s.execute("text.thread", &json!({"from":first,"to":incoming})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); + assert_eq!(link(&mut s, linked)["outOfDate"], true); + assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, "PARENT"); + let bytes = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name":"links.designcraft","base64":bytes["base64"]})).unwrap(); + assert_eq!(link(&mut reopened, linked)["parent"], survivor.0); + reopened.execute("story.updateLink", &json!({"story":linked.0})).unwrap(); + assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "PREFIX\nPARENT"); + assert_eq!(link(&mut reopened, linked)["outOfDate"], false); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], parent.0); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); +} + +#[test] +fn safety_linked_child_cannot_merge_body_text_in_either_direction() { + for reverse in [false, true] { + let mut s = setup(); + let (_, parent) = frame(&mut s, "SOURCE"); + let (linked, child) = child(&mut s, parent); + let (body, _) = frame(&mut s, "BODY"); + let (from, to) = if reverse { (body, linked) } else { (linked, body) }; + let before = state(&s); + let error = s.execute("text.thread", &json!({"from":from,"to":to})).unwrap_err(); + assert!(error.to_string().contains("linked story")); + assert_eq!(state(&s), before); + s.execute("story.updateLink", &json!({"story":child.0})).unwrap(); + assert!(s.doc().unwrap().doc.stories.values().any(|st| st.text == "BODY")); + } +} + +#[test] +fn safety_linked_child_allows_empty_extensions_in_both_directions() { + for reverse in [false, true] { + let mut s = setup(); + let (_, parent) = frame(&mut s, "SOURCE"); + let (linked, child) = child(&mut s, parent); + let (empty, _) = frame(&mut s, ""); + let (from, to) = if reverse { (empty, linked) } else { (linked, empty) }; + s.execute("text.thread", &json!({"from":from,"to":to})).unwrap(); + assert_eq!(link(&mut s, child)["parent"], parent.0); + s.execute("story.setText", &json!({"story":parent.0,"text":"UPDATED"})).unwrap(); + assert_eq!(link(&mut s, child)["outOfDate"], true); + s.execute("story.updateLink", &json!({"story":child.0})).unwrap(); + let st = s.doc().unwrap().doc.story(child).unwrap(); + assert_eq!(st.text, "UPDATED"); + assert_eq!(st.frames.len(), 2); + assert_eq!(link(&mut s, child)["outOfDate"], false); + } +} + +#[test] +fn safety_linked_empty_parent_cannot_be_merged_into_its_child() { + let mut s = setup(); + let (parent_frame, parent) = frame(&mut s, ""); + let (linked, _) = child(&mut s, parent); + let before = state(&s); + assert!(s.execute("text.thread", &json!({"from":parent_frame,"to":linked})).is_err()); + assert_eq!(state(&s), before); +} + +#[test] +fn safety_text_color_without_target_is_atomic_and_carets_remain_valid() { + let mut s = setup(); + let f = s.execute("frame.create", &json!({"rect":[36,36,200,100]})).unwrap(); + s.execute("selection.set", &json!({"ids":[f["id"]]})).unwrap(); + let before = state(&s); + assert!(s.execute("object.color", &json!({"color":"#123456","text":true})).is_err()); + assert_eq!(state(&s), before); + for text in ["", "Words"] { + let (_, sid) = frame(&mut s, text); + s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap(); + let color = s.execute("object.color", &json!({"color":"#654321","text":true})).unwrap(); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.fill.as_deref(), color["swatch"].as_str()); + } +} + +fn nested_story(depth: usize, name: &str) -> Story { + let mut st = Story::with_text(StoryId(0), "leaf", ParaFormat::default()); + st.chars[0].format.over.fill = Some(name.into()); + for _ in 0..depth { + let mut outer = Story::with_text(StoryId(0), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default()); + let mut t = Table::new(1, 1, 1, 0, 0, 100.); + t.cells[0].text = st; + outer.tables.insert(1, Arc::new(t)); + outer.paras[0].table = Some(1); + st = outer; + } + st +} + +#[test] +fn safety_deep_swatch_rename_rejects_atomically_with_pending_color() { + // A small boundary probe, never an attempted stack-overflow reproduction. + for depth in [33, 34] { + let mut s = setup(); + let (_, sid) = frame(&mut s, "root"); + s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap(); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string(); + s.edit(|d, _| { + let st = d.story_mut(sid).unwrap(); + let frames = st.frames.clone(); + *st = nested_story(depth, &old); + st.id = sid; + st.frames = frames; + Ok(()) + }) + .unwrap(); + let pending = s.execute("type.selectionAttrs", &json!({})).unwrap(); + let before = state(&s); + let doc = s.doc().unwrap().doc.clone(); + let error = s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap_err(); + assert!(error.to_string().contains("nesting")); + assert!(Arc::ptr_eq(&doc, &s.doc().unwrap().doc)); + assert_eq!(state(&s), before); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap(), pending); + } +} + +#[test] +fn safety_swatch_rename_reaches_last_supported_depth() { + let mut s = setup(); + let (_, sid) = frame(&mut s, "root"); + s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap(); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string(); + s.edit(|d, _| { + let st = d.story_mut(sid).unwrap(); + let frames = st.frames.clone(); + *st = nested_story(32, &old); + st.id = sid; + st.frames = frames; + Ok(()) + }) + .unwrap(); + s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap(); + let mut st = s.doc().unwrap().doc.story(sid).unwrap(); + for _ in 0..32 { + st = &st.tables[&1].cells[0].text; + } + assert_eq!(st.format_after(0).over.fill.as_deref(), Some("Brand")); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Brand"); +} + +#[test] +fn safety_empty_linked_parent_follows_survivor_only_on_explicit_update() { + for incoming_empty in [false, true] { + for alternate_layout in [false, true] { + let mut s = setup(); + let (empty, parent) = frame(&mut s, ""); + let linked = if alternate_layout { + s.execute("layout.createAlternate", &json!({"name":"Alternate","width":600,"height":800})).unwrap(); + let links = s.execute("story.links", &json!({})).unwrap(); + StoryId(links.as_array().unwrap().iter().find(|l| l["parent"] == parent.0).unwrap()["story"].as_u64().unwrap()) + } else { + child(&mut s, parent).1 + }; + let (body, survivor) = frame(&mut s, "BODY"); + let (from, to) = if incoming_empty { (body, empty) } else { (empty, body) }; + s.execute("text.thread", &json!({"from":from,"to":to})).unwrap(); + assert!(s.doc().unwrap().doc.story(parent).is_none()); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); + assert_eq!(link(&mut s, linked)["outOfDate"], true); + assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, ""); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], parent.0); + assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, ""); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); + let bytes = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name":"empty-parent.designcraft","base64":bytes["base64"]})).unwrap(); + assert_eq!(link(&mut reopened, linked)["outOfDate"], true); + assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, ""); + reopened.execute("story.updateLink", &json!({"story":linked.0})).unwrap(); + assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "BODY"); + assert_eq!(link(&mut reopened, linked)["outOfDate"], false); + } + } +} diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 643f59341..77b509d0b 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -322,7 +322,10 @@ pub fn stylesheet(doc: &Document) -> String { let mut rule = String::new(); para_css(&pp, base, lead, &mut rule); char_css(doc, &cp, base, &mut rule); - let _ = writeln!(css, "p.{} {{ {rule}}}", pc[ps.name.as_str()]); + let (tag, class) = export_mapping(st, &ps.name, false, &pc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } } for cs in &st.character { if cs.name == story::NO_CHAR_STYLE { @@ -347,7 +350,10 @@ pub fn stylesheet(doc: &Document) -> String { if let Some(sz) = c.size { let _ = write!(rule, "font-size: {:.3}em; ", sz / base); } - let _ = writeln!(css, "span.{} {{ {rule}}}", cc[cs.name.as_str()]); + let (tag, class) = export_mapping(st, &cs.name, true, &cc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } } css } @@ -386,6 +392,39 @@ fn is_tag(t: &str, character: bool) -> bool { } } +/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated, +/// collision-free class when no custom class was supplied, and the existing tag fallback. +fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) { + let et = st.export_tag(name, character); + let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" }); + let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name)); + // Match the characters xml_attr actually emits, before escaping either output context. + let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + (tag, class) +} + +/// Each class token must match on the same element. Escape CSS identifier punctuation and +/// leading digits without changing the class written into XHTML. +fn class_selector(tag: &str, class: &str) -> Option { + let mut tokens = class.split_ascii_whitespace().peekable(); + // A nonempty but whitespace-only custom class has no token to select. Never emit a bare + // element selector, which would apply this style to unrelated paragraphs or character runs. + tokens.peek()?; + let mut selector = tag.to_string(); + for token in tokens { + selector.push('.'); + for (i, c) in token.chars().enumerate() { + let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-')); + if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) { + selector.push(c); + } else { + let _ = write!(selector, "\\{:x} ", c as u32); + } + } + } + Some(selector) +} + /// One story as XHTML paragraphs. pub fn story_html(doc: &Document, sid: StoryId) -> String { let Some(st) = doc.story(sid) else { return String::new() }; @@ -394,10 +433,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { for (pi, r) in st.para_ranges().iter().enumerate() { let pf = &st.paras[pi]; // Export Tagging: the style's element and class. - let et = doc.styles.export_tag(&pf.style, false); - let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, false)).unwrap_or("p"); - let class = - et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| pc.get(pf.style.as_str()).cloned()).unwrap_or_else(|| slug(&pf.style)); + let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc); let text_empty = st.text[r.clone()].trim().is_empty(); let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); @@ -427,17 +463,11 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { } else { story_markup(raw) }; - let et = (f.style != story::NO_CHAR_STYLE).then(|| doc.styles.export_tag(&f.style, true)).flatten(); - let ctag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, true)).unwrap_or("span"); - let cls = if f.style != story::NO_CHAR_STYLE { - let class = et - .map(|e| e.class.clone()) - .filter(|c| !c.is_empty()) - .or_else(|| cc.get(f.style.as_str()).cloned()) - .unwrap_or_else(|| slug(&f.style)); - format!(" class=\"{}\"", xml_attr(&class)) + let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { + let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc); + (tag, format!(" class=\"{}\"", xml_attr(&class))) } else { - String::new() + ("span", String::new()) }; let style = override_css(doc, &f.over); let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/export_tag_styles.rs new file mode 100644 index 000000000..47485b532 --- /dev/null +++ b/crates/epub/tests/export_tag_styles.rs @@ -0,0 +1,138 @@ +use std::io::{Cursor, Read}; + +use designcraft_doc::build::NewDocument; +use designcraft_doc::geom::Rect; +use designcraft_doc::{Align, CharAttrs, CharacterStyle, Document, ExportTag, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, story}; +use designcraft_epub::{EpubOptions, HtmlOptions, export_epub, export_html}; + +fn fixture() -> Document { + let mut doc = Document::new(&NewDocument::default()); + let styles = doc.styles_mut(); + styles.paragraph.iter_mut().find(|p| p.name == story::BASIC_PARAGRAPH).unwrap().chars.size = Some(12.0); + styles.paragraph.push(ParagraphStyle { + name: "Chapter Title".into(), + based_on: None, + next_style: None, + para: ParaAttrs { align: Some(Align::Center), ..Default::default() }, + chars: CharAttrs { size: Some(24.0), ..Default::default() }, + shortcut: String::new(), + }); + styles.character.push(CharacterStyle { + name: "Key Term".into(), + based_on: None, + chars: CharAttrs { size: Some(18.0), ..Default::default() }, + shortcut: String::new(), + }); + let layer = doc.default_layer(); + doc.add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 300.0, 100.0), + layer, + "Heading", + ParaFormat { style: "Chapter Title".into(), ..Default::default() }, + ) + .unwrap(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 120.0, 300.0, 200.0), layer, "Accent plain", ParaFormat::default()).unwrap(); + doc.story_mut(sid).unwrap().format_chars(0..6, |f| f.style = "Key Term".into()); + doc +} + +fn map(doc: &mut Document, paragraph_tag: &str, paragraph_class: &str, character_tag: &str, character_class: &str) { + let tags = &mut doc.styles_mut().export_tags; + tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into() }); + tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into() }); +} + +fn package(doc: &Document) -> (String, String) { + let mut zip = zip::ZipArchive::new(Cursor::new(export_epub(doc, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut text = String::new(); + zip.by_name(path).unwrap().read_to_string(&mut text).unwrap(); + text + }; + let xhtml = read("OEBPS/content.xhtml"); + let css = read("OEBPS/style.css"); + let opf = read("OEBPS/content.opf"); + assert!(xhtml.contains("")); + assert!(opf.contains("href=\"style.css\" media-type=\"text/css\"")); + (xhtml, css) +} + +fn rule<'a>(css: &'a str, selector: &str) -> &'a str { + css.lines().find_map(|line| line.strip_prefix(&format!("{selector} {{ "))).unwrap_or_else(|| panic!("missing {selector} in {css}")) +} + +#[test] +fn mapped_tags_and_classes_keep_their_packaged_style_declarations() { + let mut doc = fixture(); + map(&mut doc, "h1", "chapter", "em", "term"); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains("

Heading

"), "{xhtml}"); + assert!(xhtml.contains("Accent plain"), "{xhtml}"); + assert!(rule(&css, "h1.chapter").contains("font-size: 2.000em;")); + assert!(rule(&css, "h1.chapter").contains("text-align: center;")); + assert!(rule(&css, "em.term").contains("font-size: 1.500em;")); + assert!(!css.contains("p.chapter-title {") && !css.contains("span.key-term {")); + assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;")); + // HTML uses the same mapped stylesheet inline, while EPUB links its packaged CSS. + let html = export_html(&doc, &HtmlOptions::default()); + assert!(html.contains(&css)); + assert!(html.contains("

Heading

")); +} + +#[test] +fn tag_only_class_only_and_unsupported_tags_keep_existing_fallbacks() { + for (pt, pc, ct, cc, paragraph, character) in [ + ("h2", "", "em", "", "h2.chapter-title", "em.key-term"), + ("", "chapter", "", "term", "p.chapter", "span.term"), + ("section", "", "h2", "", "p.chapter-title", "span.key-term"), + ("", "", "", "", "p.chapter-title", "span.key-term"), + ] { + let mut doc = fixture(); + map(&mut doc, pt, pc, ct, cc); + let (xhtml, css) = package(&doc); + for (selector, text, size) in [(paragraph, "Heading", "2.000em"), (character, "Accent", "1.500em")] { + let (tag, class) = selector.split_once('.').unwrap(); + assert!(xhtml.contains(&format!("<{tag} class=\"{class}\">{text}")), "{xhtml}"); + assert!(rule(&css, selector).contains(&format!("font-size: {size};"))); + } + } + let (xhtml, css) = package(&fixture()); + assert!(xhtml.contains("

Heading

")); + assert!(rule(&css, "p.chapter-title").contains("font-size: 2.000em;")); + assert!(rule(&css, "span.key-term").contains("font-size: 1.500em;")); +} + +#[test] +fn custom_class_tokens_match_without_renaming_them() { + for (class, attribute, selector) in [ + ("lead wide", "lead wide", ".lead.wide"), + ("lead\twide\n", "lead\twide\n", ".lead.wide"), + ("chapter:lead", "chapter:lead", r".chapter\3a lead"), + ("2nd-edition", "2nd-edition", r".\32 nd-edition"), + ("-2nd", "-2nd", r".-\32 nd"), + ("-", "-", r".\2d "), + ("見出し.café", "見出し.café", r".見出し\2e café"), + ("edition \"wide\"", "edition "wide"", r".edition.\22 wide\22 "), + ] { + let mut doc = fixture(); + map(&mut doc, "h2", class, "em", class); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains(&format!("

Heading

")), "{xhtml}"); + assert!(xhtml.contains(&format!("Accent")), "{xhtml}"); + assert!(rule(&css, &format!("h2{selector}")).contains("font-size: 2.000em;")); + assert!(rule(&css, &format!("em{selector}")).contains("font-size: 1.500em;")); + } +} + +#[test] +fn whitespace_only_custom_classes_do_not_apply_rules_to_every_element() { + let mut doc = fixture(); + map(&mut doc, "p", " \t", "span", " \n"); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains("

Heading

")); + assert!(xhtml.contains("Accent")); + assert!(!css.lines().any(|line| line.starts_with("p {") || line.starts_with("span {"))); + assert!(!css.contains("font-size: 2.000em;") && !css.contains("font-size: 1.500em;")); + assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;")); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 35d4f87fd..bc9c9e65f 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -125,6 +125,8 @@ pub struct FontFace { em: std::sync::OnceLock<((f64, f64), bool)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option)>, + /// Format, class and PANOSE numbers (the font menus), read on first use. + kind: std::sync::OnceLock, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -192,6 +194,32 @@ impl FontFace { pub fn skrifa(&self) -> Option> { skrifa::FontRef::from_index(self.data(), self.index).ok() } + /// The face's format, class and PANOSE numbers, as the font menus show and filter them. + pub fn kind(&self) -> crate::kind::FaceKind { + *self.kind.get_or_init(|| { + let mut kind = self.skrifa().map(|f| crate::kind::read_kind(&f, &self.family)).unwrap_or_default(); + // Nothing in the tables or the name: look at the letters. + if kind.class == crate::kind::FontClass::Other + && let Some(serifs) = has_serifs(self) + { + kind.class = if serifs { crate::kind::FontClass::Serif } else { crate::kind::FontClass::SansSerif }; + } + kind + }) + } + /// When the font file was added (created, else last modified): the font menus' Show Recently + /// Added. `None` for fonts without a file. + pub fn added(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + let meta = std::fs::metadata(self.source.path()?).ok()?; + meta.created().or_else(|_| meta.modified()).ok() + } + #[cfg(target_arch = "wasm32")] + { + None + } + } pub fn hb(&self) -> Option> { harfrust::FontRef::from_index(self.data(), self.index).ok() } @@ -445,6 +473,29 @@ const KOREAN_FALLBACKS: &[&str] = &[ "Source Han Sans K", ]; +/// Fonts made for Malayalam (macOS, Windows, Noto, Linux). A font that only maps the characters +/// (Arial Unicode MS, first by path on macOS) sets them without conjuncts or vowel signs. +const MALAYALAM_FALLBACKS: &[&str] = &[ + "Malayalam Sangam MN", + "Malayalam MN", + "Nirmala UI", + "Kartika", + "Noto Sans Malayalam", + "Noto Serif Malayalam", + "Manjari", + "Rachana", + "Meera", + "Lohit Malayalam", +]; + +/// The fallback chain of a script that only fonts made for it set well, whatever the language. +fn script_chain(c: char) -> Option<&'static [&'static str]> { + match c as u32 { + 0x0D00..=0x0D7F => Some(MALAYALAM_FALLBACKS), + _ => None, + } +} + /// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order /// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`]. fn japanese_chain() -> &'static [&'static str] { @@ -728,6 +779,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s bmp: std::sync::OnceLock::new(), em: std::sync::OnceLock::new(), group: std::sync::OnceLock::new(), + kind: std::sync::OnceLock::new(), }) } @@ -1399,6 +1451,43 @@ impl ScopedFonts<'_> { None } + /// `family`'s kind (format, class, PANOSE) and when its file was added, read from its Regular + /// face (else its first), for the font menus' filters, which look at every family. A face + /// already loaded is asked; an installed family that isn't loaded is read from its file, which + /// is then let go rather than kept loaded. None for an unknown family or an unreadable file. + pub fn family_kind(&self, family: &str) -> Option<(crate::kind::FaceKind, Option)> { + let styles = self.styles(family); + let style = styles.iter().find(|s| *s == "Regular").or(styles.first())?.clone(); + if self.own().iter().chain(self.db.read_faces().iter()).any(|f| f.family.eq_ignore_ascii_case(family)) { + let f = self.find(family, &style)?; + return Some((f.kind(), f.added())); + } + #[cfg(not(target_arch = "wasm32"))] + { + let path = { + let cat = self.db.read_catalog(); + let of_family = |c: &&CatalogEntry| c.family.eq_ignore_ascii_case(family); + let entry = cat.iter().filter(of_family).find(|c| norm(&c.style) == norm(&style)).or_else(|| cat.iter().find(of_family)); + entry.map(|c| c.path.clone())? + }; + if std::fs::metadata(&path).ok()?.len() > MAX_DOCUMENT_FONT_BYTES { + return None; + } + let data = Arc::new(std::fs::read(&path).ok()?); + let found = enumerate_faces(&data); + let pick = found + .iter() + .find(|(_, f, st, _)| f.eq_ignore_ascii_case(family) && norm(st) == norm(&style)) + .or_else(|| found.iter().find(|(_, f, _, _)| f.eq_ignore_ascii_case(family)))? + .clone(); + let (index, fam, st, coords) = pick; + let face = make_face(FontBytes::Owned(data.clone()), FontSource::Installed(path), index, fam, st, coords)?; + Some((face.kind(), face.added())) + } + #[cfg(target_arch = "wasm32")] + None + } + /// Is `family` available (the document's, loaded, or installed on the system), as named or /// without a format suffix (see [`ScopedFonts::face`])? pub fn has_family(&self, family: &str) -> bool { @@ -1497,12 +1586,12 @@ impl ScopedFonts<'_> { } /// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when - /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the - /// first face (fallback family first, then load order, the document's fonts last) that covers - /// `c`. On native, system fonts are cataloged and loaded lazily the first time no loaded face - /// covers a character. + /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]), Malayalam + /// the fonts made for it (see [`script_chain`]); then the first face (fallback family first, + /// then load order, the document's fonts last) that covers `c`. On native, system fonts are + /// cataloged and loaded lazily the first time no loaded face covers a character. pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option> { - if let Some(chain) = cjk_chain(c, language) + if let Some(chain) = cjk_chain(c, language).or_else(|| script_chain(c)) && let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude)) { return Some(f); @@ -1554,6 +1643,53 @@ fn over_total() -> String { format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20) } +/// Does the face's "l" (else "I") have serifs: much wider at its foot than mid-stem? `None` when +/// it has neither letter. +fn has_serifs(face: &FontFace) -> Option { + let gid = ['l', 'I'].into_iter().map(|c| face.glyph_for(c)).find(|g| *g != 0)?; + let path = font_outline(face, gid); + let bbox = kurbo::Shape::bounding_box(&path); + // An empty or degenerate outline (NaN included) says nothing. + if bbox.height().is_nan() || bbox.height() <= 1.0 { + return None; + } + let mut edges = Vec::new(); + let mut last = None; + let mut start = None; + kurbo::flatten(&path, 1.0, |el| match el { + kurbo::PathEl::MoveTo(p) => { + last = Some(p); + start = Some(p); + } + kurbo::PathEl::LineTo(p) => { + if let Some(a) = last { + edges.push((a, p)); + } + last = Some(p); + } + kurbo::PathEl::ClosePath => { + if let (Some(a), Some(b)) = (last, start) { + edges.push((a, b)); + } + last = start; + } + _ => {} + }); + // The outline is y-down: the foot is near bbox.y1 (the baseline). + let width_at = |y: f64| { + let xs: Vec = edges + .iter() + .filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y)) + .map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y)) + .collect(); + let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + (hi > lo).then_some(hi - lo) + }; + let foot = width_at(bbox.y1 - bbox.height() * 0.02)?; + let stem = width_at(bbox.y0 + bbox.height() * 0.5)?; + Some(foot > stem * 1.6) +} + /// Glyph `gid`'s outline as the font draws it, in font units, y-down (flipped). fn font_outline(face: &FontFace, gid: u32) -> BezPath { let mut pen = FlipPen(BezPath::new()); diff --git a/crates/fonts/src/kind.rs b/crates/fonts/src/kind.rs new file mode 100644 index 000000000..6a224bc00 --- /dev/null +++ b/crates/fonts/src/kind.rs @@ -0,0 +1,191 @@ +//! What the font menus say about a face: its file format (the icon after the sample), its +//! classification (the Filters menu) and its PANOSE numbers (Show Similar Fonts). + +use skrifa::raw::TableProvider; +use skrifa::raw::types::Tag; + +/// A face's font format, as the font menus show it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FontFormat { + /// TrueType outlines (`glyf`). + TrueType, + /// PostScript (CFF) outlines. + OpenType, + /// A variable font (`fvar`). + Variable, + /// Colour glyphs (COLR, SVG, bitmaps): emoji and colour fonts. + Color, +} + +/// The classes the font menus filter by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FontClass { + Serif, + SlabSerif, + SansSerif, + Script, + Monospaced, + Decorative, + Symbol, + /// Nothing in the font says. + Other, +} + +impl FontClass { + /// The classes, in the Filters menu's order. + pub const ALL: [FontClass; 8] = [ + FontClass::Serif, + FontClass::SlabSerif, + FontClass::SansSerif, + FontClass::Script, + FontClass::Monospaced, + FontClass::Decorative, + FontClass::Symbol, + FontClass::Other, + ]; + + /// The name the Filters menu shows (English; the UI translates it). + pub fn label(self) -> &'static str { + match self { + FontClass::Serif => "Serif", + FontClass::SlabSerif => "Slab Serif", + FontClass::SansSerif => "Sans Serif", + FontClass::Script => "Script", + FontClass::Monospaced => "Monospaced", + FontClass::Decorative => "Decorative", + FontClass::Symbol => "Symbol", + FontClass::Other => "Other", + } + } +} + +/// A face's format, class and PANOSE numbers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FaceKind { + pub format: FontFormat, + pub class: FontClass, + /// The OS/2 table's PANOSE classification (all zero when the font has none). + pub panose: [u8; 10], +} + +impl Default for FaceKind { + fn default() -> Self { + FaceKind { format: FontFormat::TrueType, class: FontClass::Other, panose: [0; 10] } + } +} + +/// Read a face's kind from its tables; `family` helps classify fonts whose tables don't say. +pub fn read_kind(f: &skrifa::FontRef<'_>, family: &str) -> FaceKind { + let has = |tag: &[u8; 4]| f.table_data(Tag::new(tag)).is_some(); + let format = if [b"COLR", b"SVG ", b"CBDT", b"sbix"].iter().any(|t| has(t)) { + FontFormat::Color + } else if has(b"fvar") { + FontFormat::Variable + } else if has(b"CFF ") || has(b"CFF2") { + FontFormat::OpenType + } else { + FontFormat::TrueType + }; + let os2 = f.os2().ok(); + let panose: [u8; 10] = os2.as_ref().and_then(|t| t.panose_10().try_into().ok()).unwrap_or([0; 10]); + let ibm = os2.as_ref().map_or(0, |t| (t.s_family_class() >> 8) as u8); + let fixed = f.post().ok().is_some_and(|p| p.is_fixed_pitch() != 0); + FaceKind { format, class: classify(&panose, ibm, fixed, family), panose } +} + +/// The class from PANOSE (family kind, serif style, proportion), else the IBM family class in OS/2, +/// else the family name. +pub fn classify(panose: &[u8; 10], ibm_class: u8, fixed_pitch: bool, family: &str) -> FontClass { + let name = family.to_lowercase(); + let named = |words: &[&str]| words.iter().any(|w| name.contains(w)); + // PANOSE proportion 9 is monospaced (Latin text). + if fixed_pitch || (panose[0] == 2 && panose[3] == 9) || named(&["mono", "courier", " code"]) { + return FontClass::Monospaced; + } + match panose[0] { + 3 => return FontClass::Script, + 4 => return FontClass::Decorative, + 5 => return FontClass::Symbol, + 2 => match panose[1] { + // Square serifs are slabs; cove, bone, triangle… serifs are serifs. + 6 => return FontClass::SlabSerif, + 2..=10 => return FontClass::Serif, + 11..=15 => return FontClass::SansSerif, + _ => {} + }, + _ => {} + } + match ibm_class { + 1 | 2 | 3 | 7 => FontClass::Serif, + 4 | 5 => FontClass::SlabSerif, + 8 => FontClass::SansSerif, + 9 => FontClass::Decorative, + 10 => FontClass::Script, + 12 => FontClass::Symbol, + _ if named(&["slab"]) => FontClass::SlabSerif, + _ if named(&["sans", "grotesk", "grotesque", "gothic", "helvetica", "arial"]) => FontClass::SansSerif, + _ if named(&["script", "hand", "brush"]) => FontClass::Script, + _ if named(&["serif"]) => FontClass::Serif, + _ => FontClass::Other, + } +} + +/// How unlike two faces look by their PANOSE numbers (0 = alike), counting only the digits both +/// fonts set; `None` when they share none. +pub fn panose_distance(a: &[u8; 10], b: &[u8; 10]) -> Option { + let pairs: Vec<(u8, u8)> = a.iter().zip(b).skip(1).filter(|(x, y)| **x > 1 && **y > 1).map(|(x, y)| (*x, *y)).collect(); + if pairs.is_empty() { + return None; + } + Some(pairs.iter().map(|(x, y)| u32::from(x.abs_diff(*y))).sum::() * 9 / pairs.len() as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classes_come_from_panose_then_the_ibm_class_then_the_name() { + let latin = |serif: u8, proportion: u8| [2, serif, 5, proportion, 5, 0, 0, 0, 0, 0]; + assert_eq!(classify(&latin(2, 3), 0, false, "Garamond"), FontClass::Serif); + assert_eq!(classify(&latin(6, 3), 0, false, "Rockwell"), FontClass::SlabSerif); + assert_eq!(classify(&latin(11, 3), 0, false, "Inter"), FontClass::SansSerif); + assert_eq!(classify(&latin(11, 9), 0, false, "Inter"), FontClass::Monospaced, "proportion 9"); + assert_eq!(classify(&[3, 0, 0, 0, 0, 0, 0, 0, 0, 0], 0, false, "Brush"), FontClass::Script); + assert_eq!(classify(&[0; 10], 8, false, "Untitled"), FontClass::SansSerif, "IBM class 8"); + assert_eq!(classify(&[0; 10], 0, true, "Untitled"), FontClass::Monospaced, "post.isFixedPitch"); + assert_eq!(classify(&[0; 10], 0, false, "JetBrains Mono"), FontClass::Monospaced); + assert_eq!(classify(&[0; 10], 0, false, "Roboto Slab"), FontClass::SlabSerif); + assert_eq!(classify(&[0; 10], 0, false, "Plain"), FontClass::Other); + } + + #[test] + fn bundled_fonts_are_classified() { + let db = crate::FontDb::global(); + for (family, class) in [ + ("Source Serif 4", FontClass::Serif), + ("Source Sans 3", FontClass::SansSerif), + ("Inter", FontClass::SansSerif), + ("JetBrains Mono", FontClass::Monospaced), + ] { + let kind = db.face(family, "Regular").kind(); + assert_eq!(kind.class, class, "{family}"); + assert_eq!(kind.format, FontFormat::TrueType, "{family}"); + } + let serif = db.face("Source Serif 4", "Regular").kind().panose; + let sans = db.face("Source Sans 3", "Regular").kind().panose; + let serif_bold = db.face("Source Serif 4", "Bold").kind().panose; + if let (Some(near), Some(far)) = (panose_distance(&serif, &serif_bold), panose_distance(&serif, &sans)) { + assert!(near < far, "the bold serif is nearer the serif than the sans is ({near} vs {far})"); + } + } + + #[test] + fn panose_distance_counts_shared_digits() { + let a = [2, 2, 5, 3, 5, 4, 5, 2, 2, 4]; + assert_eq!(panose_distance(&a, &a), Some(0)); + let b = [2, 11, 5, 3, 5, 4, 5, 2, 2, 4]; + assert!(panose_distance(&a, &b).is_some_and(|d| d > 0)); + assert_eq!(panose_distance(&a, &[0; 10]), None, "no PANOSE: nothing to compare"); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 2c5d7782b..5ff395df8 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -9,6 +9,7 @@ mod fontdb; mod group; +mod kind; #[cfg(any(test, feature = "testing"))] pub mod testing; mod vertical; @@ -20,6 +21,7 @@ pub use fontdb::{ pub use group::{FamilyInfo, FontGroup, sort_for_menu}; pub use harfrust::Feature; use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer}; +pub use kind::{FaceKind, FontClass, FontFormat, classify, panose_distance}; pub use kurbo::BezPath; use skrifa::MetadataProvider; use skrifa::instance::Size; diff --git a/crates/fonts/src/tests_fallback.rs b/crates/fonts/src/tests_fallback.rs index 4d81f9a5b..d9e27ac62 100644 --- a/crates/fonts/src/tests_fallback.rs +++ b/crates/fonts/src/tests_fallback.rs @@ -127,3 +127,33 @@ fn chain_fonts_load_from_the_font_folders_when_first_needed() { assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some(first_loaded("Other CJK"))); assert!(!db.is_loaded("AppleMyungjo")); } + +const KA: char = 'ക'; +const VIRAMA: char = '\u{0D4D}'; +const AA: char = '\u{0D3E}'; + +#[test] +fn malayalam_takes_a_font_made_for_it() { + // Arial Unicode MS maps Malayalam but can't shape it (no conjuncts); loaded first, it won. + let db = db_with(&[("Arial Unicode MS", &[KA, VIRAMA, AA]), ("Malayalam Sangam MN", &[KA, VIRAMA, AA])]); + for c in [KA, VIRAMA, AA] { + for language in [None, Some("en-US"), Some("ml")] { + assert_eq!(family(&db, c, language).as_deref(), Some("Malayalam Sangam MN"), "{c:?} {language:?}"); + } + } + // Past the Malayalam fonts, the language-blind search as before. + let sangam = db.face("Malayalam Sangam MN", "Regular"); + assert_eq!(db.fallback_for(KA, sangam.id(), None).map(|f| f.family.clone()).as_deref(), Some("Arial Unicode MS")); +} + +#[test] +fn malayalam_fonts_load_from_the_font_folders_when_first_needed() { + let dir = std::env::temp_dir().join(format!("dc-malayalam-fallback-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("noto.ttf"), font_with("Noto Sans Malayalam", &[KA]).unwrap()).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + db.add_font(font_with("Arial Unicode MS", &[KA]).unwrap()); + assert!(!db.is_loaded("Noto Sans Malayalam")); + assert_eq!(family(&db, KA, None).as_deref(), Some("Noto Sans Malayalam")); +} diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index a9cda7cdc..ca82ad4d7 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -126,6 +126,23 @@ fn a_link_back_to_a_parent_folder_ends_the_scan() { assert_eq!(db.load_system_fonts(), 2); } +#[test] +fn a_familys_kind_is_read_without_keeping_it_loaded() { + let dir = font_dir("kind"); + let db = FontDb::with_font_dirs(vec![dir]); + db.load_system_fonts(); + let fonts = db.scoped(0); + let (kind, added) = fonts.family_kind(FAMILY).expect("an installed family"); + assert_eq!(kind.class, crate::kind::FontClass::SansSerif); + assert!(added.is_some(), "the file's date"); + assert!(!db.is_loaded(FAMILY), "the file was read and let go"); + // A loaded family is asked directly, with the same answer. + let _ = fonts.face(FAMILY, "Regular"); + assert!(db.is_loaded(FAMILY)); + assert_eq!(fonts.family_kind(FAMILY).map(|(k, _)| k.class), Some(crate::kind::FontClass::SansSerif)); + assert!(fonts.family_kind("No Such Family").is_none()); +} + #[test] fn the_scan_reads_names_without_loading_the_font() { let dir = font_dir("names"); diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index bcbdf9005..3eecacdc8 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -1,9 +1,10 @@ //! Live corner options (Object → Corner Options): rounded, inverse rounded, inset, bevel, fancy. //! -//! Corners are applied at render time to the straight-segment corners of a closed path, so the -//! frame keeps its simple editable geometry (as in InDesign). +//! Corners are applied at render time to the corner points of a closed path (where its direction +//! turns), so the frame keeps its simple editable geometry (as in InDesign). Curved sides stay +//! curved: a corner where an arc meets a line trims the arc along its length. -use kurbo::{BezPath, Point, Vec2}; +use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, Point, Vec2}; use serde::{Deserialize, Serialize}; use crate::path::{PathData, SubPath}; @@ -63,8 +64,9 @@ impl CornerOptions { } } -/// Apply corner options to `path`. Corners are applied to anchors joining two straight segments of -/// closed subpaths; other anchors are kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`. +/// Apply corner options to `path`. Corners are applied to the corner points of closed subpaths +/// (where the incoming and outgoing directions differ, curved sides included); smooth points are +/// kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`. pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath { if opts.is_none() { return path.to_bezpath(); @@ -76,50 +78,84 @@ pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath { out } +/// Arc length accuracy (points) when trimming curved sides. +const ARC_ACCURACY: f64 = 1e-3; +/// Directions closer than this (cosine, about 1°) are a smooth point, not a corner. +const SMOOTH: f64 = 0.9998; + +/// The unit direction a segment leaves its start in (None for a point). +fn start_dir(c: &CubicBez) -> Option { + [c.p1, c.p2, c.p3].into_iter().map(|q| q - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// The unit direction a segment arrives at its end in (None for a point). +fn end_dir(c: &CubicBez) -> Option { + [c.p2, c.p1, c.p0].into_iter().map(|q| c.p3 - q).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// Does the path turn where `prev` ends and `next` starts? +fn is_corner(prev: &CubicBez, next: &CubicBez) -> bool { + matches!((end_dir(prev), start_dir(next)), (Some(a), Some(b)) if a.dot(b) < SMOOTH) +} + fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { let n = sp.anchors.len(); - let straight_corner = |i: usize| { - let a = &sp.anchors[i]; - sp.closed && !a.has_in() && !a.has_out() - }; - if !sp.closed || n < 3 || !(0..n).any(straight_corner) { + if !sp.closed || n < 2 { sp.to_bezpath_into(out); return; } - let pts: Vec = sp.anchors.iter().map(|a| a.p).collect(); - let mut first = true; - for i in 0..n { - let c = opts.corners[i % 4]; - let p = pts[i]; - let prev = pts[(i + n - 1) % n]; - let next = pts[(i + 1) % n]; - if c.is_none() || !straight_corner(i) || !straight_corner((i + n - 1) % n) && sp.anchors[(i + n - 1) % n].has_out() { - if first { - out.move_to(p); - first = false; - } else { - out.line_to(p); + // Segment `i` runs from anchor `i` to anchor `i + 1`; the corner at anchor `i` joins segments + // `i - 1` and `i`, and takes at most half of each. + let segs: Vec = (0..n).map(|i| sp.segment(i)).collect(); + let lines: Vec = (0..n).map(|i| sp.segment_is_line(i)).collect(); + // Straight sides are measured (and trimmed below) exactly; curves by arc length. + let lens: Vec = segs.iter().zip(&lines).map(|(c, line)| if *line { (c.p3 - c.p0).hypot() } else { c.arclen(ARC_ACCURACY) }).collect(); + let size: Vec = (0..n) + .map(|i| { + let c = opts.corners[i % 4]; + let prev = (i + n - 1) % n; + if c.is_none() || !is_corner(&segs[prev], &segs[i]) { + return 0.0; } - continue; - } - let din = prev - p; - let dout = next - p; - let (lin, lout) = (din.hypot(), dout.hypot()); - if lin < 1e-9 || lout < 1e-9 { - continue; - } - let s = c.size.min(lin / 2.0).min(lout / 2.0); - let ui = din / lin; - let uo = dout / lout; - let a = p + ui * s; // entry point on incoming edge - let b = p + uo * s; // exit point on outgoing edge - if first { - out.move_to(a); - first = false; + c.size.min(lens[prev] / 2.0).min(lens[i] / 2.0).max(0.0) + }) + .collect(); + if size.iter().all(|s| *s <= 0.0) { + sp.to_bezpath_into(out); + return; + } + // Each segment without the parts its corners take. + let trimmed: Vec = (0..n) + .map(|i| { + let (s0, s1) = (size[i], size[(i + 1) % n]); + if lines[i] { + let c = &segs[i]; + let u = if lens[i] > 1e-12 { (c.p3 - c.p0) / lens[i] } else { Vec2::ZERO }; + let (a, b) = (c.p0 + u * s0, c.p3 - u * s1); + return CubicBez::new(a, a, b, b); + } + let t0 = if s0 > 0.0 { segs[i].inv_arclen(s0, ARC_ACCURACY) } else { 0.0 }; + let t1 = if s1 > 0.0 { segs[i].inv_arclen(lens[i] - s1, ARC_ACCURACY) } else { 1.0 }; + segs[i].subsegment(t0..t1.max(t0)) + }) + .collect(); + out.move_to(trimmed[0].p0); + for i in 0..n { + let seg = &trimmed[i]; + if lines[i] { + out.line_to(seg.p3); } else { - out.line_to(a); + out.curve_to(seg.p1, seg.p2, seg.p3); + } + // The corner at the segment's end, from where this segment stops to where the next starts. + let j = (i + 1) % n; + let next = &trimmed[j]; + if size[j] > 0.0 { + match (end_dir(seg).or_else(|| end_dir(&segs[i])), start_dir(next).or_else(|| start_dir(&segs[j]))) { + (Some(ta), Some(tb)) => corner_shape(out, opts.corners[j % 4].shape, sp.anchors[j].p, seg.p3, next.p0, -ta, tb, size[j]), + _ => out.line_to(next.p0), + } } - corner_shape(out, c.shape, p, a, b, ui, uo, s); } out.close_path(); } @@ -211,6 +247,67 @@ mod tests { assert!((crate::shapes::KAPPA - 0.5523).abs() < 1e-4); } + fn nearest(bp: &BezPath, p: Point) -> f64 { + use kurbo::ParamCurveNearest; + bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq.sqrt()).fold(f64::INFINITY, f64::min) + } + + /// A quarter disc of radius 100 centred on (0, 100): its arc from (0, 0) to (100, 100). + fn quarter_disc() -> PathData { + let k = 100.0 * crate::shapes::KAPPA; + let mut a0 = crate::path::Anchor::corner(Point::new(0.0, 0.0)); + a0.h_out = Point::new(k, 0.0); + let mut a1 = crate::path::Anchor::corner(Point::new(100.0, 100.0)); + a1.h_in = Point::new(100.0, 100.0 - k); + let a2 = crate::path::Anchor::corner(Point::new(0.0, 100.0)); + PathData::new(vec![SubPath { anchors: vec![a0, a1, a2], closed: true }]) + } + + #[test] + fn rounded_corners_keep_curved_sides_and_round_where_an_arc_meets_a_line() { + let p = quarter_disc(); + let bp = apply(&p, &CornerOptions::uniform(CornerShape::Rounded, 20.0)); + // Still a quarter disc (area pi/4 * 100^2 = 7854), less three small rounded corners; a + // triangle would be 5000. + let area = bp.area().abs(); + assert!(area > 7300.0 && area < 7854.0, "area {area}"); + // The middle of the arc is kept. + let mid = Point::new(100.0 * std::f64::consts::FRAC_1_SQRT_2, 100.0 - 100.0 * std::f64::consts::FRAC_1_SQRT_2); + assert!(nearest(&bp, mid) < 0.05, "{}", nearest(&bp, mid)); + // All three corners are rounded off: the line-line one and both where the arc meets a line. + for corner in [Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)] { + assert!(nearest(&bp, corner) > 3.0, "{corner:?} is still sharp"); + } + } + + #[test] + fn straight_sides_are_trimmed_exactly() { + let r = rectangle(Rect::new(0.0, 0.0, 100.0, 60.0)); + let bp = apply(&r, &CornerOptions::uniform(CornerShape::Bevel, 10.0)); + // Every point a bevelled rectangle goes through sits exactly 10 pt from a corner. + let pts: Vec = bp + .elements() + .iter() + .filter_map(|e| match e { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => Some(*p), + _ => None, + }) + .collect(); + assert_eq!(pts.len(), 9, "{pts:?}"); + for p in pts { + assert!([0.0, 10.0, 90.0, 100.0].contains(&p.x) && [0.0, 10.0, 50.0, 60.0].contains(&p.y), "{p:?}"); + } + } + + #[test] + fn smooth_points_are_not_corners() { + let e = crate::shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 60.0)); + for shape in CornerShape::ALL.into_iter().skip(1) { + let bp = apply(&e, &CornerOptions::uniform(shape, 20.0)); + assert!((bp.area().abs() - e.to_bezpath().area().abs()).abs() < 1e-6, "{shape:?}"); + } + } + #[test] fn size_is_clamped_to_half_edge() { let r = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index fe6b89078..34041d280 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -47,6 +47,22 @@ impl PathWarp { None } + /// The arc length (as `at` measures it, so flipped paths count from their end) of the point + /// of the path nearest `p`. + pub fn nearest(&self, p: Point) -> f64 { + use kurbo::ParamCurveNearest; + let mut best = (f64::INFINITY, 0.0); + let mut before = 0.0; + for (seg, len) in &self.segs { + let n = seg.nearest(p, 1e-6); + if n.distance_sq < best.0 { + best = (n.distance_sq, before + seg.subsegment(0.0..n.t).arclen(1e-4)); + } + before += len; + } + if self.flip { self.total - best.1 } else { best.1 } + } + /// Transform for something laid out on a straight line (x along the line, y down from the /// line) whose horizontal centre is `mid`: it lands centred at arc length `mid`, turned with /// the path, its line at `baseline` on the path. @@ -76,5 +92,10 @@ mod tests { assert!((g * Point::new(50.0, 10.0) - Point::new(50.0, 0.0)).hypot() < 1e-9); let flipped = PathWarp::new(&bp, true); assert!((flipped.at(0.0).unwrap().0 - Point::new(100.0, 100.0)).hypot() < 1e-9); + // The nearest point by arc length, off the path and on it; flipped counts from the end. + assert!((w.nearest(Point::new(120.0, 50.0)) - 150.0).abs() < 1e-6); + assert!((w.nearest(Point::new(120.0, 150.0)) - 200.0).abs() < 1e-6, "past the end: the end"); + assert!((w.nearest(Point::new(30.0, -8.0)) - 30.0).abs() < 1e-6); + assert!((flipped.nearest(Point::new(30.0, -8.0)) - 170.0).abs() < 1e-6); } } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 6fce9c77c..b7e5ca3c6 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -33,10 +33,17 @@ impl Default for ExportOptions { } /// Export a document as an IDML package (ZIP bytes). +/// +/// IDML has one sort key per index topic path. If native references disagree, the first +/// reference serialized for that path supplies its key, including an empty/default key. +/// Later references cannot overwrite it; no synthetic topic names or sort keys are created. +/// Consistent references retain their keys. Conflicting per-reference keys cannot all survive +/// this topic-level representation. This policy also applies to [`export_idml_with`]. pub fn export_idml(doc: &Document) -> Vec { export_idml_with(doc, &ExportOptions::default()) } +/// Export with options, using the index sort-key conflict policy of [`export_idml`]. pub fn export_idml_with(doc: &Document, opts: &ExportOptions) -> Vec { let mut ex = Ex::new(doc, opts); let parts = ex.build(); @@ -133,8 +140,8 @@ struct Ex<'a> { threads: HashMap, Option)>, /// Cross-reference sources written so far: (Self, target anchor). xref_sources: Vec<(String, u64)>, - /// Index topics (paths) and their See / See also cross-references. - index_topics: BTreeSet>, + /// Index topic path → first serialized sort key; See / See also cross-references. + index_topics: BTreeMap, String>, index_xrefs: Vec<(Vec, &'static str, String)>, } @@ -148,7 +155,7 @@ impl<'a> Ex<'a> { fonts: BTreeMap::new(), threads: HashMap::new(), xref_sources: Vec::new(), - index_topics: BTreeSet::new(), + index_topics: BTreeMap::new(), index_xrefs: Vec::new(), }; ex.assign_swatch_ids(); @@ -1397,7 +1404,7 @@ impl<'a> Ex<'a> { n!(drop_cap_lines, "DropCapLines"); n!(drop_cap_chars, "DropCapCharacters"); if a.drop_cap_align_left.is_some() || a.drop_cap_scale_descenders.is_some() { - let d = designcraft_doc::ParaProps::default(); + let d = parent.map(|name| self.d.styles.resolve_para_style(name).0).unwrap_or_default(); let bits = u32::from(a.drop_cap_align_left.unwrap_or(d.drop_cap_align_left)) | u32::from(a.drop_cap_scale_descenders.unwrap_or(d.drop_cap_scale_descenders)) << 1; el.set("DropcapDetail", bits); @@ -1665,7 +1672,7 @@ impl<'a> Ex<'a> { .attr("TextAlignment", names::path_align_out(pt.align)) .attr("FlipPathEffect", if pt.flip { "Flipped" } else { "NotFlipped" }) .attr("StartBracket", num(pt.start)) - .attr("EndBracket", num(len)), + .attr("EndBracket", num(pt.end.map_or(len, |e| e.min(len)))), ); } else if let Content::Text(t) = &it.content { let (prev, next) = self.threads.get(&it.id.0).copied().unwrap_or((None, None)); @@ -1845,12 +1852,12 @@ impl<'a> Ex<'a> { fn index_el(&mut self) -> El { let mut topics = self.index_topics.clone(); for (_, _, target) in &self.index_xrefs { - topics.insert(vec![target.clone()]); + topics.entry(vec![target.clone()]).or_default(); } - fn build(ex: &mut Ex, topics: &BTreeSet>, prefix: &[String]) -> Vec { + fn build(ex: &mut Ex, topics: &BTreeMap, String>, prefix: &[String]) -> Vec { let mut out = Vec::new(); - for t in topics.iter().filter(|t| t.len() == prefix.len() + 1 && t.starts_with(prefix)) { - let mut el = El::new("Topic").attr("Self", topic_self(t)).attr("SortOrder", "").attr("Name", t.last().map_or("", String::as_str)); + for (t, sort) in topics.iter().filter(|(t, _)| t.len() == prefix.len() + 1 && t.starts_with(prefix)) { + let mut el = El::new("Topic").attr("Self", topic_self(t)).attr("SortOrder", sort).attr("Name", t.last().map_or("", String::as_str)); for (from, kind, target) in ex.index_xrefs.clone() { if from == *t { el.push( @@ -2105,7 +2112,9 @@ impl<'a> Ex<'a> { if let Some(r) = s.index_refs.get(k).filter(|r| !r.topics.is_empty()) { use designcraft_doc::index::IndexRange as R; for n in 1..=r.topics.len() { - self.index_topics.insert(r.topics[..n].to_vec()); + // A shared topic has one key: keep the first reference's + // value (even empty), rather than inventing a merged key. + self.index_topics.entry(r.topics[..n].to_vec()).or_insert_with(|| r.sort.get(n - 1).cloned().unwrap_or_default()); } let (kind, limit) = match &r.range { R::CurrentPage => (Some("CurrentPage"), None), diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 4ae4d15db..1a4fa65e3 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -206,11 +206,32 @@ struct Importer<'r> { /// Cross-reference format Self → name. xref_format_names: HashMap, xref_formats: Vec, - /// Index topic Self → topic path; topic See / See also cross-references. - index_topics: HashMap>, + /// Index topic Self → topic path and per-level sort keys; topic cross-references. + index_topics: HashMap, index_xrefs: Vec, } +/// IDML stores a sort key on each topic, rather than on its page references. +#[derive(Clone, Default)] +struct IndexTopic { + path: Vec, + sort: Vec, +} + +impl IndexTopic { + fn child(&self, topic: &El) -> Self { + let mut path = self.path.clone(); + path.push(topic.get("Name").unwrap_or("").to_string()); + let mut sort = self.sort.clone(); + if let Some(key) = topic.get("SortOrder").filter(|key| !key.is_empty()) { + // Keep empty positions before a child's key, but omit trailing defaults. + sort.resize(self.path.len(), String::new()); + sort.push(key.to_string()); + } + Self { path, sort } + } +} + fn lab_to_color(l: f32, a: f32, b: f32) -> Color { let rgb = lab::xyz_to_srgb(lab::lab_to_xyz(lab::Lab::new(l, a, b))); Color::rgb(rgb[0], rgb[1], rgb[2]) @@ -456,7 +477,7 @@ impl<'r> Importer<'r> { self.xref_format(e); } if let Some(ix) = top.iter().find(|e| e.local() == "Index") { - self.index_topics_of(ix, &[]); + self.index_topics_of(ix, &IndexTopic::default()); } // Stories (ids first so frames can reference them). for e in top.iter().filter(|e| e.local() == "Story") { @@ -772,8 +793,8 @@ impl<'r> Importer<'r> { /// A `TextFramePreference`, with its column rule colour resolved to a swatch. fn frame_options(&mut self, prefs: &[&El]) -> TextFrameOptions { let mut o = text_frame_options(prefs); - if let Some(r) = Prefs(prefs).get("ColumnRuleStrokeColor") { - o.column_rule_color = self.swatch_ref(r); + if let Some(r) = Prefs(prefs).prop("ColumnRuleStrokeColor") { + o.column_rule_color = self.swatch_ref(&r); } o } @@ -1013,33 +1034,31 @@ impl<'r> Importer<'r> { } /// Index topics (recursively); at the root, their See / See also cross-references too. - fn index_topics_of(&mut self, e: &El, prefix: &[String]) { + fn index_topics_of(&mut self, e: &El, prefix: &IndexTopic) { for t in e.find_all("Topic") { - let mut path = prefix.to_vec(); - path.push(t.get("Name").unwrap_or("").to_string()); + let topic = prefix.child(t); if let Some(me) = t.get("Self") { - self.index_topics.insert(me.to_string(), path.clone()); + self.index_topics.insert(me.to_string(), topic.clone()); } - self.index_topics_of(t, &path); + self.index_topics_of(t, &topic); } - if !prefix.is_empty() { + if !prefix.path.is_empty() { return; } - let mut stack = vec![(e, Vec::::new())]; - while let Some((el, path)) = stack.pop() { + let mut stack = vec![(e, IndexTopic::default())]; + while let Some((el, prefix)) = stack.pop() { for t in el.find_all("Topic") { - let mut p = path.clone(); - p.push(t.get("Name").unwrap_or("").to_string()); + let topic = prefix.child(t); for x in t.find_all("CrossReference") { - let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|p| p.join(": ")).unwrap_or_default(); + let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|t| t.path.join(": ")).unwrap_or_default(); use designcraft_doc::index::IndexRange as R; let range = match x.get("CrossReferenceType") { Some("SeeAlso") | Some("SeeAlsoHerein") => R::SeeAlso(target), _ => R::See(target), }; - self.index_xrefs.push(designcraft_doc::IndexRef { topics: p.clone(), sort: vec![], range }); + self.index_xrefs.push(designcraft_doc::IndexRef { topics: topic.path.clone(), sort: topic.sort.clone(), range }); } - stack.push((t, p)); + stack.push((t, topic)); } } } @@ -1810,14 +1829,14 @@ impl<'r> Importer<'r> { } "PageReference" => { use designcraft_doc::index::IndexRange as R; - let topics = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default(); + let topic = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default(); let range = match c.get("PageReferenceType") { Some("ToEndOfStory") | Some("ToEndOfDocument") | Some("ToEndOfSection") => R::ToEndOfStory, Some("ForNextNParagraphs") => R::NextParagraphs(c.num("PageReferenceLimit").unwrap_or(1.0).max(1.0) as u32), Some("SuppressPageNumbers") => R::SuppressPageRange, _ => R::CurrentPage, }; - b.index_refs.push(designcraft_doc::IndexRef { topics, sort: vec![], range }); + b.index_refs.push(designcraft_doc::IndexRef { topics: topic.path, sort: topic.sort, range }); b.push(&designcraft_doc::INDEX_MARK.to_string(), cf); } "CrossReferenceSource" => { @@ -2205,6 +2224,11 @@ impl<'r> Importer<'r> { let options = TextFrameOptions { path: Some(designcraft_doc::PathType { start: tp.num("StartBracket").unwrap_or(0.0).max(0.0), + // At (or past) the path's length it is the path's end: no end bracket. + end: tp + .num("EndBracket") + .filter(|e| e.is_finite() && *e >= 0.0) + .filter(|e| *e < designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length() - 0.01), flip: tp.get("FlipPathEffect") == Some("Flipped"), align: names::path_align_in(tp.get("TextAlignment").unwrap_or("")), }), @@ -2231,7 +2255,7 @@ impl<'r> Importer<'r> { let prefs: Vec<&El> = std::iter::once(e).chain(self.object_style_chain(e)).filter_map(|s| s.find("TextFramePreference")).collect(); let mut options = text_frame_options(&prefs); // `prefs` borrows the object styles, so the rule colour's swatch is resolved after it. - if let Some(r) = Prefs(&prefs).get("ColumnRuleStrokeColor").map(str::to_string) { + if let Some(r) = Prefs(&prefs).prop("ColumnRuleStrokeColor") { options.column_rule_color = self.swatch_ref(&r); } if let Some(g) = e.find("BaselineFrameGridOption") @@ -2554,10 +2578,10 @@ fn ace_char(v: &str) -> Option { } fn margins_of(m: &El) -> (Margins, Columns) { - let g = |k: &str, d: f64| m.num(k).unwrap_or(d); + let g = |k: &str, d: f64| m.num(k).filter(|v| v.is_finite()).unwrap_or(d); ( Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) }, - Columns { count: g("ColumnCount", 1.0).max(1.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None }, + Columns { count: g("ColumnCount", 1.0).clamp(1.0, 216.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None }, ) } @@ -2566,14 +2590,14 @@ fn text_frame_options(prefs: &[&El]) -> TextFrameOptions { let e = Prefs(prefs); let mut o = TextFrameOptions::default(); if let Some(v) = e.num("TextColumnCount") { - o.columns = v.max(1.0) as u32; + o.columns = v.clamp(1.0, 216.0) as u32; } if let Some(v) = e.num("TextColumnGutter") { o.gutter = v; } - o.columns_kind = if e.get("UseFixedColumnWidth") == Some("true") { + o.columns_kind = if e.prop("UseFixedColumnWidth").as_deref().map(str::trim) == Some("true") { ColumnsKind::FixedWidth - } else if e.get("UseFlexibleColumnWidth") == Some("true") { + } else if e.prop("UseFlexibleColumnWidth").as_deref().map(str::trim) == Some("true") { ColumnsKind::FlexibleWidth } else { ColumnsKind::FixedNumber @@ -2583,33 +2607,33 @@ fn text_frame_options(prefs: &[&El]) -> TextFrameOptions { { o.column_width = v; } - o.balance_columns = e.get("VerticalBalanceColumns") == Some("true"); + o.balance_columns = e.prop("VerticalBalanceColumns").as_deref().map(str::trim) == Some("true"); if let Some(p) = prefs.iter().find(|p| p.prop("InsetSpacing").is_some()) && let Some(inset) = inset_spacing(p) { o.inset = inset; } - if let Some(v) = e.get("VerticalJustification") { - o.vertical_justification = names::vj_in(v); + if let Some(v) = e.prop("VerticalJustification") { + o.vertical_justification = names::vj_in(v.trim()); } if let Some(v) = e.num("VerticalThreshold") { o.vj_paragraph_spacing_limit = v; } - if let Some(v) = e.get("FirstBaselineOffset") { - o.first_baseline = names::first_baseline_in(v); + if let Some(v) = e.prop("FirstBaselineOffset") { + o.first_baseline = names::first_baseline_in(v.trim()); } if let Some(v) = e.num("MinimumFirstBaselineOffset") { o.first_baseline_min = v; } - o.ignore_wrap = e.get("IgnoreWrap") == Some("true"); - if let Some(v) = e.get("AutoSizingType") { - o.auto_size = names::auto_size_in(v); + o.ignore_wrap = e.prop("IgnoreWrap").as_deref().map(str::trim) == Some("true"); + if let Some(v) = e.prop("AutoSizingType") { + o.auto_size = names::auto_size_in(v.trim()); } - if let Some(v) = e.get("AutoSizingReferencePoint") { - o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v).unwrap_or(1) as u8; + if let Some(v) = e.prop("AutoSizingReferencePoint") { + o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v.trim()).unwrap_or(1) as u8; } // Column rules (the colour is a swatch reference: see `Importer::frame_options`). - o.column_rule = e.get("ColumnRuleOverride") == Some("true"); + o.column_rule = e.prop("ColumnRuleOverride").as_deref().map(str::trim) == Some("true"); let finite = |k: &str| e.num(k).filter(|v| v.is_finite()); if let Some(v) = finite("ColumnRuleStrokeWidth") { o.column_rule_weight = v.clamp(0.0, 1000.0); @@ -2670,15 +2694,16 @@ fn inherits_drop_cap_style<'a>(styles: &'a Styles, mut name: Option<&'a str>) -> false } -/// Attribute lookup over several elements: the first that has it. +/// Property lookup over preference levels: the nearest level wins, attributes before children. struct Prefs<'a>(&'a [&'a El]); impl Prefs<'_> { - fn get(&self, k: &str) -> Option<&str> { - self.0.iter().find_map(|e| e.get(k)) + fn prop(&self, k: &str) -> Option { + self.0.iter().find_map(|e| e.prop(k)) } fn num(&self, k: &str) -> Option { - self.get(k).and_then(|v| v.trim().parse().ok()) + // The nearest preference wins; within it, attributes precede property children. + self.prop(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) } } @@ -2738,3 +2763,244 @@ pub(crate) fn uri_to_path(uri: &str) -> String { }; percent_decode(rest) } + +#[cfg(test)] +mod numeric_property_tests { + use super::*; + + fn pref(attrs: &[(&str, &str)], props: &[(&str, &str)]) -> El { + let mut e = El::new("TextFramePreference"); + for (key, value) in attrs { + e.set(key, *value); + } + let mut p = El::new("Properties"); + for (key, value) in props { + p.push(El::new(key).text(*value)); + } + e.push(p); + e + } + + #[test] + fn numeric_property_forms_import_every_frame_option() { + let local = pref( + &[("UseFixedColumnWidth", "true")], + &[ + ("TextColumnCount", "3"), + ("TextColumnGutter", "17"), + ("TextColumnFixedWidth", "123"), + ("VerticalThreshold", "19"), + ("MinimumFirstBaselineOffset", "8"), + ("ColumnRuleStrokeWidth", "2"), + ("ColumnRuleStrokeTint", "25"), + ("ColumnRuleOffset", "3"), + ("ColumnRuleTopInset", "4"), + ("ColumnRuleBottomInset", "5"), + ], + ); + let o = text_frame_options(&[&local]); + assert_eq!((o.columns, o.gutter, o.column_width), (3, 17., 123.)); + assert_eq!((o.vj_paragraph_spacing_limit, o.first_baseline_min), (19., 8.)); + assert_eq!( + (o.column_rule_weight, o.column_rule_tint, o.column_rule_offset, o.column_rule_top_inset, o.column_rule_bottom_inset), + (2., 0.25, 3., 4., 5.) + ); + } + + #[test] + fn numeric_property_precedence_is_nearest_level_then_attribute() { + let local = pref(&[("TextColumnCount", "4")], &[("TextColumnCount", "5"), ("TextColumnGutter", "17")]); + let child = pref( + &[("TextColumnGutter", "90"), ("MinimumFirstBaselineOffset", "8")], + &[("MinimumFirstBaselineOffset", "9"), ("VerticalThreshold", "19")], + ); + let base = pref(&[("VerticalThreshold", "99")], &[("ColumnRuleStrokeWidth", "2")]); + let o = text_frame_options(&[&local, &child, &base]); + assert_eq!((o.columns, o.gutter, o.first_baseline_min, o.vj_paragraph_spacing_limit, o.column_rule_weight), (4, 17., 8., 19., 2.)); + // An explicit invalid value still masks inherited values, as attribute lookup did. + let invalid = pref(&[("TextColumnCount", "bad")], &[("TextColumnCount", "6")]); + assert_eq!(text_frame_options(&[&invalid, &local]).columns, TextFrameOptions::default().columns); + } +} + +#[cfg(test)] +mod frame_preference_safety_tests { + use super::*; + + fn pref(attrs: &[(&str, &str)], props: &[(&str, &str)]) -> El { + let mut e = El::new("TextFramePreference"); + for (key, value) in attrs { + e.set(key, *value); + } + let mut p = El::new("Properties"); + for (key, value) in props { + p.push(El::new(key).text(*value)); + } + e.push(p); + e + } + + #[test] + fn frame_preference_nonfinite_numbers_keep_finite_defaults() { + const KEYS: [&str; 10] = [ + "TextColumnCount", + "TextColumnGutter", + "TextColumnFixedWidth", + "VerticalThreshold", + "MinimumFirstBaselineOffset", + "ColumnRuleStrokeWidth", + "ColumnRuleStrokeTint", + "ColumnRuleOffset", + "ColumnRuleTopInset", + "ColumnRuleBottomInset", + ]; + let fallback = pref(&[("UseFixedColumnWidth", "true")], &[]); + let expected = text_frame_options(&[&fallback]); + for value in ["NaN", "inf", "-inf", "1e309"] { + for attribute in [true, false] { + let values: Vec<_> = KEYS.iter().map(|key| (*key, value)).collect(); + let mut invalid = if attribute { pref(&values, &[]) } else { pref(&[], &values) }; + invalid.set("UseFixedColumnWidth", "true"); + let inherited = pref(&[], &[("TextColumnCount", "7"), ("TextColumnGutter", "9")]); + assert_eq!(text_frame_options(&[&invalid, &inherited]), expected, "{value}, attribute={attribute}"); + } + } + } + + #[test] + fn frame_preference_property_booleans_enums_and_precedence() { + let base = pref( + &[ + ("UseFixedColumnWidth", "true"), + ("UseFlexibleColumnWidth", "false"), + ("VerticalBalanceColumns", "false"), + ("IgnoreWrap", "false"), + ("ColumnRuleOverride", "false"), + ("VerticalJustification", "TopAlign"), + ("FirstBaselineOffset", "AscentOffset"), + ("AutoSizingType", "Off"), + ("AutoSizingReferencePoint", "TopCenterPoint"), + ], + &[], + ); + let local = pref( + &[], + &[ + ("UseFixedColumnWidth", "false"), + ("UseFlexibleColumnWidth", "true"), + ("VerticalBalanceColumns", "true"), + ("IgnoreWrap", "true"), + ("ColumnRuleOverride", "true"), + ("VerticalJustification", "BottomAlign"), + ("FirstBaselineOffset", "FixedHeight"), + ("AutoSizingType", "HeightAndWidth"), + ("AutoSizingReferencePoint", "BottomRightPoint"), + ], + ); + let o = text_frame_options(&[&local, &base]); + assert_eq!(o.columns_kind, ColumnsKind::FlexibleWidth); + assert!(o.balance_columns && o.ignore_wrap && o.column_rule); + assert_eq!(o.vertical_justification, designcraft_doc::VerticalJustification::Bottom); + assert_eq!(o.first_baseline, designcraft_doc::FirstBaseline::Fixed); + assert_eq!(o.auto_size, designcraft_doc::AutoSize::HeightAndWidth); + assert_eq!(o.auto_size_ref, 8); + let fixed = pref(&[], &[("UseFixedColumnWidth", "true")]); + assert_eq!(text_frame_options(&[&fixed, &local, &base]).columns_kind, ColumnsKind::FixedWidth); + // At one level an attribute wins over a conflicting property child, for every key. + let mut same_level = local.clone(); + for (key, value) in &base.attrs { + same_level.set(key, value); + } + assert_eq!(text_frame_options(&[&same_level, &local]), text_frame_options(&[&base])); + // Malformed nearer values mask ancestors and use the normal per-field fallback. + let mut malformed = El::new("TextFramePreference"); + for (key, _) in &base.attrs { + malformed.set(key, "invalid"); + } + assert_eq!(text_frame_options(&[&malformed, &local]), TextFrameOptions { auto_size_ref: 1, ..Default::default() }); + } +} + +#[cfg(test)] +mod page_column_import_safety_tests { + use super::*; + + #[test] + fn frame_preference_page_columns_are_bounded_and_finite() { + for (raw, expected) in [("4000000000", 216), ("1e300", 216), ("-10", 1), ("NaN", 1), ("inf", 1)] { + let m = El::new("MarginPreference") + .attr("ColumnCount", raw) + .attr("ColumnGutter", "NaN") + .attr("Top", "inf") + .attr("Bottom", "-inf") + .attr("Left", "NaN") + .attr("Right", "1e309"); + let (margins, columns) = margins_of(&m); + assert_eq!(columns.count, expected); + assert_eq!(columns.gutter, 12.); + assert_eq!(margins, Margins::uniform(36.)); + } + } +} + +#[cfg(test)] +mod frame_preference_scalar_tests { + use super::*; + + #[test] + fn frame_preference_scalar_whitespace() { + let values = [ + ("UseFixedColumnWidth", "true"), + ("UseFlexibleColumnWidth", "false"), + ("VerticalBalanceColumns", "true"), + ("IgnoreWrap", "true"), + ("ColumnRuleOverride", "true"), + ("VerticalJustification", "BottomAlign"), + ("FirstBaselineOffset", "FixedHeight"), + ("AutoSizingType", "HeightAndWidth"), + ("AutoSizingReferencePoint", "BottomRightPoint"), + ]; + let preference = |properties: bool, padded: bool, fixed: bool| { + let mut e = El::new("TextFramePreference"); + let mut props = El::new("Properties"); + for (key, raw) in values { + let raw = match key { + "UseFixedColumnWidth" if !fixed => "false", + "UseFlexibleColumnWidth" if !fixed => "true", + _ => raw, + }; + let value = if padded { format!("\n {raw}\t ") } else { raw.to_string() }; + if properties { + props.push(El::new(key).text(value)); + } else { + e.set(key, value); + } + } + e.push(props); + e + }; + for fixed in [false, true] { + let clean = preference(false, false, fixed); + let expected = text_frame_options(&[&clean]); + assert_eq!(expected.columns_kind, if fixed { ColumnsKind::FixedWidth } else { ColumnsKind::FlexibleWidth }); + assert!(expected.balance_columns && expected.ignore_wrap && expected.column_rule); + assert_eq!(expected.vertical_justification, designcraft_doc::VerticalJustification::Bottom); + assert_eq!(expected.first_baseline, designcraft_doc::FirstBaseline::Fixed); + assert_eq!(expected.auto_size, designcraft_doc::AutoSize::HeightAndWidth); + assert_eq!(expected.auto_size_ref, 8); + for properties in [false, true] { + let padded = preference(properties, true, fixed); + assert_eq!(text_frame_options(&[&padded]), expected); + } + // A malformed nearest attribute masks both its own valid child and farther values. + let mut invalid = preference(true, true, fixed); + for (key, _) in values { + invalid.set(key, " invalid "); + } + assert_eq!(text_frame_options(&[&invalid, &clean]), TextFrameOptions { auto_size_ref: 1, ..Default::default() }); + } + // Object identities use the raw property lookup, not scalar whitespace normalization. + let exact = El::new("TextFramePreference").child(El::new("Properties").child(El::new("ColumnRuleStrokeColor").text(" Color/Named "))); + assert_eq!(Prefs(&[&exact]).prop("ColumnRuleStrokeColor").as_deref(), Some(" Color/Named ")); + } +} diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index ebd591ce5..57801e34f 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -9,6 +9,7 @@ use super::*; mod assets; mod decorations; +mod index_sort; fn zip_files(files: &[(&str, &str)]) -> Vec { use zip::write::SimpleFileOptions; @@ -2102,3 +2103,69 @@ fn frames_inherit_object_style_corners_and_text_frame_options() { assert_eq!((o.vertical_justification, o.first_baseline, o.first_baseline_min), (VerticalJustification::Center, FirstBaseline::Leading, 4.0)); assert_eq!((o.ignore_wrap, o.auto_size, o.auto_size_ref), (true, AutoSize::HeightOnly, 4)); } +#[test] +fn type_on_a_path_keeps_its_start_and_end_brackets() { + use designcraft_geom::{Anchor, PathData, Point, SubPath}; + for end in [Some(250.0), None] { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let line = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(100.0, 300.0)), Anchor::corner(Point::new(500.0, 300.0))], false)); + let mut it = designcraft_doc::Item::new(id, lid, Shape::GraphicLine, line); + let sid = designcraft_doc::StoryId(d.alloc()); + let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() }; + tf.options.path = Some(designcraft_doc::PathType { start: 30.0, end, ..Default::default() }); + it.content = designcraft_doc::Content::Text(tf); + let mut story = designcraft_doc::Story::with_text(sid, "Along", Default::default()); + story.frames = vec![id]; + d.stories.insert(sid, std::sync::Arc::new(story)); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let back = import_idml(&export_idml(&d)).unwrap(); + let pt = back.spreads[0].items[0].text_frame().unwrap().options.path.clone().unwrap(); + assert!((pt.start - 30.0).abs() < 1e-6, "{pt:?}"); + assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket"); + } +} + +#[test] +fn frame_preference_import_caps_column_count_without_composition() { + for (raw, expected) in [("4000000000", 216), ("1e300", 216), ("-100", 1), ("0", 1), ("NaN", 1), ("inf", 1), ("-inf", 1)] { + for attribute in [true, false] { + let preference = if attribute { + format!(r#""#) + } else { + format!( + r#"{raw}"# + ) + }; + let document = format!( + r#"{preference}"# + ); + let d = import_idml_with(&zip_files(&[("designmap.xml", &document)]), &|_| None).unwrap(); + let frame = d.spreads[0].items[0].text_frame().unwrap(); + // Inspect imported data only: never compose an unbounded input value. + assert_eq!(frame.options.columns, expected, "{raw}, attribute={attribute}"); + } + } +} + +#[test] +fn frame_preference_property_color_resolves_for_frames_and_object_styles() { + let document = r#" + + + + +ObjectStyle/BaseColor/Near + + +Color/Near + +Color/Near +missing +"#; + let d = import_idml_with(&zip_files(&[("designmap.xml", document)]), &|_| None).unwrap(); + let colors: Vec<_> = d.spreads[0].items.iter().map(|it| it.text_frame().unwrap().options.column_rule_color.as_str()).collect(); + assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); + assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); +} diff --git a/crates/idml/src/tests/index_sort.rs b/crates/idml/src/tests/index_sort.rs new file mode 100644 index 000000000..e35e7fed9 --- /dev/null +++ b/crates/idml/src/tests/index_sort.rs @@ -0,0 +1,240 @@ +//! Original synthetic fixtures: no Adobe-generated files or implementation details. +//! Behavioral reference: https://developer.adobe.com/indesign/uxp/dom/api/t/topic/ + +use std::io::Read; + +use designcraft_doc::IndexRef; +use designcraft_doc::index::{self, IndexRange, IndexSpec, Resolved}; + +use super::*; +use crate::xml::El; + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|v| (*v).to_string()).collect() +} + +fn reference(topics: &[&str], sort: &[&str], range: IndexRange) -> IndexRef { + IndexRef { topics: strings(topics), sort: strings(sort), range } +} + +fn references(doc: &Document) -> Vec { + doc.stories.values().flat_map(|s| s.index_refs.iter().map(|r| r.as_ref().clone())).collect() +} + +fn native(refs: Vec) -> Document { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 400.0), layer, "Source", ParaFormat::default()).unwrap(); + let story = doc.story_mut(sid).unwrap(); + for r in refs { + story.insert_index_ref(story.len(), r); + } + doc.check().unwrap(); + doc +} + +fn import_xml(index: &str, refs: &str) -> Document { + let map = format!( + r#" + {index} + + + + "# + ); + let story = format!( + r#" + + Source{refs} + + "# + ); + let spread = r#" + + + + + + + + + "#; + import_idml(&zip_files(&[("designmap.xml", &map), ("Stories/s.xml", &story), ("Spreads/s.xml", spread)])).unwrap() +} + +/// Inspect the exported XML directly, without calling the IDML importer. +fn exported_index(doc: &Document) -> El { + let bytes = export_idml(doc); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut map = String::new(); + zip.by_name("designmap.xml").unwrap().read_to_string(&mut map).unwrap(); + crate::xml::parse(map.as_bytes()).unwrap().find("Index").unwrap().clone() +} + +fn topic<'a>(index: &'a El, path: &[&str]) -> &'a El { + let mut el = index; + for name in path { + el = el.find_all("Topic").find(|t| t.get("Name") == Some(*name)).unwrap(); + } + el +} + +#[test] +fn imports_synthetic_sort_keys_for_nested_topics_and_cross_references() { + let doc = import_xml( + r#" + + + + + + + + + + + + + "#, + r#" + + + + + "#, + ); + doc.check().unwrap(); + let refs = references(&doc); + assert_eq!(refs.len(), 8); + for r in [ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::NextParagraphs(4)), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Empty"], &[], IndexRange::SuppressPageRange), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::CurrentPage), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::See("Beta".into())), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::SeeAlso("Zulu".into())), + reference(&["Zoo", "Leaf"], &["Aardvark"], IndexRange::CurrentPage), + ] { + assert!(refs.contains(&r), "missing {r:?} in {refs:?}"); + } + let story = doc.stories.values().next().unwrap(); + assert_eq!(story.text.replace(designcraft_doc::INDEX_MARK, ""), "Source"); +} + +#[test] +fn exports_sort_keys_as_topic_attributes_without_changing_names() { + let doc = native(vec![ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::NextParagraphs(2)), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Empty"], &[""], IndexRange::CurrentPage), + reference(&["Parent", "É & "], &["", " A&<é> "], IndexRange::CurrentPage), + reference(&["Alias"], &["Able"], IndexRange::See("Zulu".into())), + reference(&["Related"], &["Azure"], IndexRange::SeeAlso("Zulu".into())), + reference(&["Parent", "Alias"], &["", "Able"], IndexRange::See("Zulu".into())), + reference(&["Parent", "Related"], &["", "Azure"], IndexRange::SeeAlso("Zulu".into())), + ]); + let index = exported_index(&doc); + assert_eq!(index.find_all("Topic").count(), 6, "repeated references and cross-reference targets share their topic"); + assert_eq!(topic(&index, &["Zulu"]).get("SortOrder"), Some("Alpha")); + assert_eq!(topic(&index, &["Beta"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Empty"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Parent"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Parent", "É & "]).get("SortOrder"), Some(" A&<é> ")); + for (name, key, kind) in [("Alias", "Able", "See"), ("Related", "Azure", "SeeAlso")] { + for path in [&[name][..], &["Parent", name][..]] { + let t = topic(&index, path); + assert_eq!(t.get("SortOrder"), Some(key)); + let cross_ref = t.find("CrossReference").unwrap(); + assert_eq!(cross_ref.get("CrossReferenceType"), Some(kind)); + assert_eq!(cross_ref.get("ReferencedTopic"), topic(&index, &["Zulu"]).get("Self")); + } + } +} + +/// These references all belong to the same page. Fix the page resolution to isolate index +/// ordering from fonts/composition, and use the real index generator for its text and headings. +fn generated_index(doc: &Document) -> Vec<(String, String)> { + let refs: Vec<_> = references(doc).into_iter().map(|r| Resolved { r, pages: Some((0, 0)) }).collect(); + index::build(&IndexSpec { title: String::new(), ..Default::default() }, &refs, &|p| (p + 1).to_string()) +} + +#[test] +fn round_trip_preserves_generated_index_order_and_headings() { + let doc = native(vec![ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Parent", "Zulu"], &["", "Alpha"], IndexRange::CurrentPage), + reference(&["Parent", "Beta"], &[], IndexRange::CurrentPage), + reference(&["Alias"], &["Able"], IndexRange::See("Zulu".into())), + reference(&["Related"], &["Delta"], IndexRange::SeeAlso("Beta".into())), + reference(&["Parent", "Alias"], &["", "Able"], IndexRange::See("Zulu".into())), + reference(&["Parent", "Related"], &["", "Azure"], IndexRange::SeeAlso("Beta".into())), + ]); + let before = generated_index(&doc); + let expected: Vec<_> = [ + ("Index Section Head", "A"), + ("Index Level 1", "Alias See Zulu"), + ("Index Level 1", "Zulu 1"), + ("Index Section Head", "B"), + ("Index Level 1", "Beta 1"), + ("Index Section Head", "D"), + ("Index Level 1", "Related 1. See also Beta"), + ("Index Section Head", "P"), + ("Index Level 1", "Parent"), + ("Index Level 2", "Alias See Zulu"), + ("Index Level 2", "Zulu 1"), + ("Index Level 2", "Related 1. See also Beta"), + ("Index Level 2", "Beta 1"), + ] + .into_iter() + .map(|(style, text)| (style.to_string(), text.to_string())) + .collect(); + assert_eq!(before, expected); + let back = import_idml(&export_idml(&doc)).unwrap(); + back.check().unwrap(); + assert_eq!(generated_index(&back), before); + assert_eq!(back.stories.values().next().unwrap().text.replace(designcraft_doc::INDEX_MARK, ""), "Source"); +} + +#[test] +fn conflicting_sort_keys_keep_the_first_serialized_value_including_defaults() { + fn check(first: &[&str], second: &[&str], expected: &str) { + let doc = native(vec![reference(&["Shared"], first, IndexRange::CurrentPage), reference(&["Shared"], second, IndexRange::CurrentPage)]); + let index = exported_index(&doc); + assert_eq!(index.find_all("Topic").count(), 1, "conflicts do not manufacture topics"); + assert_eq!(topic(&index, &["Shared"]).get("SortOrder"), Some(expected)); + let back = import_idml(&export_idml(&doc)).unwrap(); + let refs = references(&back); + assert_eq!(refs.len(), 2); + for r in refs { + assert_eq!(r.sort.first().map_or("", String::as_str), expected); + } + assert_eq!(references(&doc)[0].sort, strings(first), "export does not edit native references"); + assert_eq!(references(&doc)[1].sort, strings(second)); + } + check(&["Alpha"], &["Omega"], "Alpha"); + check(&["Omega"], &["Alpha"], "Omega"); + check(&[], &["Alpha"], ""); + check(&[""], &["Alpha"], ""); + check(&["Alpha"], &[], "Alpha"); + check(&["Alpha"], &[""], "Alpha"); +} + +#[test] +fn shared_ancestors_and_identical_leaf_names_keep_keys_by_full_path() { + let doc = native(vec![ + reference(&["One", "Zulu"], &["Alpha", "First"], IndexRange::CurrentPage), + reference(&["One", "Zulu"], &["Beta", "Second"], IndexRange::CurrentPage), + reference(&["One", "Beta"], &["Gamma", "Third"], IndexRange::CurrentPage), + reference(&["Two", "Zulu"], &["Delta", "Fourth"], IndexRange::CurrentPage), + ]); + let index = exported_index(&doc); + assert_eq!(topic(&index, &["One"]).get("SortOrder"), Some("Alpha")); + assert_eq!(topic(&index, &["One", "Zulu"]).get("SortOrder"), Some("First")); + assert_eq!(topic(&index, &["One", "Beta"]).get("SortOrder"), Some("Third")); + assert_eq!(topic(&index, &["Two"]).get("SortOrder"), Some("Delta")); + assert_eq!(topic(&index, &["Two", "Zulu"]).get("SortOrder"), Some("Fourth")); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index c4930cc99..73da6c93b 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -55,10 +55,27 @@ impl Remote { Err(last) } - fn roundtrip(&mut self, line: &str) -> std::io::Result { + fn prepare_connection(&mut self) -> std::io::Result<()> { + if let Some((reader, writer)) = self.conn.take() { + writer.set_nonblocking(true)?; + let probe = writer.peek(&mut [0]); + writer.set_nonblocking(false)?; + let live = match probe { + Ok(0) => false, + Ok(_) => true, + Err(e) => e.kind() == std::io::ErrorKind::WouldBlock, + }; + if live { + self.conn = Some((reader, writer)); + } + } if self.conn.is_none() { self.reconnect()?; } + Ok(()) + } + + fn roundtrip(&mut self, line: &str) -> std::io::Result { let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")); }; @@ -78,6 +95,10 @@ impl Backend for Remote { let id = self.next_id; self.next_id += 1; let line = json!({"id": id, "method": method, "params": params}).to_string(); + if let Err(e) = self.prepare_connection() { + self.conn = None; + return Err(format!("DesignCraft app at {} is not reachable: {e}; nothing was sent", self.addr)); + } let reply = match self.roundtrip(&line) { Ok(r) => r, Err(e) => { @@ -107,6 +128,98 @@ mod tests { use super::*; use std::net::TcpListener; + fn closed_remote() -> (Remote, String) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let mut remote = Remote::connect(&addr).unwrap(); + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); + let server = std::thread::spawn(move || { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "document.inspect"); + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": null})).unwrap(); + stream.shutdown(std::net::Shutdown::Both).unwrap(); + drop(listener); + }); + assert_eq!(remote.call("document.inspect", json!({})), Ok(Value::Null)); + server.join().unwrap(); + let stream = &remote.conn.as_ref().unwrap().1; + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + assert_eq!(stream.peek(&mut [0]).unwrap(), 0, "FIN must be observable before the next call"); + (remote, addr) + } + + #[test] + fn remote_executes_once_when_app_restarts_between_calls() { + let (mut remote, addr) = closed_remote(); + let listener = TcpListener::bind(&addr).unwrap(); + listener.set_nonblocking(true).unwrap(); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + return 0; + } + std::thread::yield_now(); + } + Err(e) => panic!("accept failed: {e}"), + } + }; + // The listener polls accept; the connection blocks while waiting for its request. + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut applied = 0; + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + match request["method"].as_str().unwrap() { + "engine.execute" => { + assert_eq!(request["params"]["command"], "frame.create"); + applied += 1; + } + "document.inspect" => {} + method => panic!("unexpected method: {method}"), + } + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap(); + if request["method"] == "document.inspect" { + return applied; + } + } + }); + + let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}})); + let inspected = match &result { + Ok(_) => remote.call("document.inspect", json!({})), + Err(_) => Err("edit failed".to_string()), + }; + let applied = server.join().unwrap(); + + assert_eq!(result, Ok(json!({"applied": 1})), "the first edit after restart must succeed"); + assert_eq!(inspected, Ok(json!({"applied": 1}))); + assert_eq!(applied, 1); + } + + #[test] + fn remote_reports_nothing_sent_when_reconnect_has_no_listener() { + let (mut remote, _addr) = closed_remote(); + + let error = remote.call("engine.execute", json!({"command": "frame.create", "params": {}})).unwrap_err(); + + assert!(error.contains("not reachable"), "{error}"); + assert!(error.contains("nothing was sent"), "{error}"); + assert!(!error.contains("operation may have completed"), "{error}"); + } + #[test] fn remote_does_not_replay_when_reply_is_lost() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -149,7 +262,7 @@ mod tests { let applied = server.join().unwrap(); assert_eq!(applied, 1, "lost reply must not replay the applied edit; client returned {result:?}"); - assert!(result.is_err(), "lost reply must report an uncertain outcome: {result:?}"); + assert!(result.unwrap_err().contains("operation may have completed"), "lost reply must report an uncertain outcome"); assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully"); } } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 0bd4a5930..cd55e3415 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -202,7 +202,7 @@ impl Headless { let png = img.to_png(); match s(p, "path") { Some(path) => { - std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})), @@ -222,7 +222,7 @@ impl Headless { .ok_or_else(|| format!("no page {page}"))?; let lower = path.to_ascii_lowercase(); let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; - std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1be5bba8a..f6d2974b0 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -480,7 +480,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result { let path = a.get("path").and_then(Value::as_str); if let Some(path) = path { let png = designcraft_engine::cmd::base64_decode(&b64); - std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; } Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path}))) } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d92fc242b..dcf8abb83 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -630,7 +630,6 @@ impl Exporter<'_> { let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue }; let parent = &doc.parents[ppi]; let dx = page.x - parent.pages[ppage].x; - let page_name = doc.page_name(first + pi); for it in &parent.items { if it.layer != layer.id || page.overridden.contains(&it.id) { continue; @@ -639,7 +638,7 @@ impl Exporter<'_> { if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 { continue; } - self.top_item(s, it, Affine::translate((dx, 0.0)), Some(&page_name), true); + self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true); } } for it in &sp.items { @@ -679,9 +678,9 @@ impl Exporter<'_> { } /// A spread or parent item, inside its structure tag when tagging. - fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>, on_parent: bool) { + fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option, on_parent: bool) { if !self.opts.tagged || it.hidden || it.nonprinting { - return self.item(s, it, parent, page_name); + return self.item(s, it, parent, page); } let story = it.text_frame().map(|t| t.story); let figure = !it.alt_text.is_empty() || matches!(it.content, Content::Graphic(_)) || it.has_nested_items(); @@ -689,7 +688,7 @@ impl Exporter<'_> { // Page furniture and decoration. let kind = if on_parent { ArtifactType::Page } else { ArtifactType::Other }; s.start_tagged(ContentTag::Artifact(Artifact::new(kind, None))); - self.item(s, it, parent, page_name); + self.item(s, it, parent, page); s.end_tagged(); return; } @@ -700,12 +699,12 @@ impl Exporter<'_> { self.tags.push(TagEntry::Story(sid)); } self.tag_story = Some(sid); - self.item(s, it, parent, page_name); + self.item(s, it, parent, page); self.tag_story = None; return; } let id = s.start_tagged(ContentTag::Other); - self.item(s, it, parent, page_name); + self.item(s, it, parent, page); s.end_tagged(); let alt = (!it.alt_text.is_empty()).then(|| it.alt_text.clone()); let mut g = TagGroup::new(Tag::Figure(alt)); @@ -751,10 +750,10 @@ impl Exporter<'_> { tree } - fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { + fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option) { let gf = &it.effects.gradient_feather; if !gf.on || it.hidden || it.nonprinting { - return self.item_inner(s, it, parent, page_name); + return self.item_inner(s, it, parent, page); } // Gradient feather: a luminosity mask (white = opaque) over the object. let xf = parent * it.xf; @@ -804,11 +803,11 @@ impl Exporter<'_> { ms.finish(); let stream = sb.finish(); s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity)); - self.item_inner(s, it, parent, page_name); + self.item_inner(s, it, parent, page); s.pop(); } - fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { + fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option) { if it.hidden || it.nonprinting { return; } @@ -843,7 +842,7 @@ impl Exporter<'_> { s.push_clip_path(c, &FillRule::NonZero); } for c in it.children() { - self.item(s, c, xf, page_name); + self.item(s, c, xf, page); } if clip.is_some() { s.pop(); @@ -863,7 +862,7 @@ impl Exporter<'_> { if !it.children().is_empty() { let pushes = Self::push_group(s, it.opacity, it.blend) + Self::push_isolation(s, it); for c in it.shown_children() { - self.item(s, c, xf, page_name); + self.item(s, c, xf, page); } for _ in 0..pushes { s.pop(); @@ -921,7 +920,7 @@ impl Exporter<'_> { } } Content::Text(tfr) => { - let cs = self.cache.get(doc, tfr.story, page_name); + let cs = self.cache.get(doc, tfr.story, page); if let (Some(ft), Some(pt)) = (cs.frame(it.id), &tfr.options.path) { // Type on a path: the glyphs as outlines along the path. for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) { @@ -966,11 +965,11 @@ impl Exporter<'_> { if let Content::Text(tfr) = &it.content && let Some(st) = doc.story(tfr.story).filter(|st| !st.objects.is_empty()) { - let cs = self.cache.get(doc, tfr.story, page_name); + let cs = self.cache.get(doc, tfr.story, page); if let Some(ft) = cs.frame(it.id) { for o in &ft.objects { if let Some(obj) = st.objects.get(o.index) { - self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page_name); + self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page); } } } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 77bb18780..6b29516f4 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -66,7 +66,7 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter { impl Renderer { /// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow. - pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) { + pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option) { let doc = f.doc; let e = &it.effects; let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() }; @@ -95,7 +95,7 @@ impl Renderer { let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; c.set_transform(moved.view); c.push_layer(None, None, None, None, Some(filter)); - me.draw_body(c, &moved, it, bp, xf, page_name); + me.draw_body(c, &moved, it, bp, xf, page); if grow > 0.0 && solid { c.set_transform(moved.view * xf); c.set_paint(peniko::Color::BLACK); @@ -119,7 +119,7 @@ impl Renderer { self.with_filters(ctx, f, reach, w / 4.0, |me, c, fr| { c.set_transform(fr.view * xf); c.push_clip_layer(bp); - me.draw_body(c, fr, it, bp, xf, page_name); + me.draw_body(c, fr, it, bp, xf, page); // Keep the body where the blurred inset silhouette is. c.set_transform(Affine::IDENTITY); c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); @@ -139,7 +139,7 @@ impl Renderer { c.pop_layer(); }); } else { - self.draw_body(ctx, f, it, bp, xf, page_name); + self.draw_body(ctx, f, it, bp, xf, page); } // Directional feather: one fading ramp per side, multiplied in. let df = &e.directional_feather; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 98b139d1c..dd2225b74 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -216,7 +216,7 @@ pub struct Renderer { resources: Resources, glyphs: text::GlyphCache, /// Composed stories looked up during the current render. - stories: HashMap<(StoryId, Option), Arc>, + stories: HashMap<(StoryId, Option), Arc>, pub threads: u16, pub stats: FrameStats, } @@ -408,7 +408,6 @@ impl Renderer { let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue }; let parent = &doc.parents[ppi]; let dx = page.x - parent.pages[ppage].x; - let page_name = doc.page_name(first + pi); let pr = parent.pages[ppage].bounds(); for it in &parent.items { if it.layer != layer.id || page.overridden.contains(&it.id) { @@ -419,7 +418,7 @@ impl Renderer { continue; } let _ = pr; - self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(&page_name)); + self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(first + pi)); } } } @@ -431,7 +430,7 @@ impl Renderer { } } - fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) { + fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: Option) { if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting { return; } @@ -458,7 +457,7 @@ impl Renderer { ctx.set_transform(f.view * xf); ctx.push_clip_layer(&bp); for c in it.children() { - self.draw_item(ctx, f, c, xf, page_name); + self.draw_item(ctx, f, c, xf, page); } ctx.pop_layer(); if !it.stroke.is_none() { @@ -489,11 +488,11 @@ impl Renderer { ctx.fill_path(&c.path.to_bezpath()); ctx.pop_layer(); } - self.draw_item(ctx, f, c, xf, page_name); + self.draw_item(ctx, f, c, xf, page); } } else { for c in it.shown_children() { - self.draw_item(ctx, f, c, xf, page_name); + self.draw_item(ctx, f, c, xf, page); } } if layered { @@ -520,9 +519,9 @@ impl Renderer { ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); } if effects { - self.draw_item_fx(ctx, f, it, &bp, xf, page_name); + self.draw_item_fx(ctx, f, it, &bp, xf, page); } else { - self.draw_body(ctx, f, it, &bp, xf, page_name); + self.draw_body(ctx, f, it, &bp, xf, page); } if layered { ctx.pop_layer(); @@ -531,18 +530,18 @@ impl Renderer { /// The composed story for a frame (memoised for the current render: the cache lookup /// validates the whole thread each time). - fn story(&mut self, f: &Frame, sid: StoryId, page_name: Option<&str>) -> Arc { - let key = (sid, page_name.map(str::to_string)); + fn story(&mut self, f: &Frame, sid: StoryId, page: Option) -> Arc { + let key = (sid, page); if let Some(cs) = self.stories.get(&key) { return cs.clone(); } - let cs = f.cache.get(f.doc, sid, page_name); + let cs = f.cache.get(f.doc, sid, page); self.stories.insert(key, cs.clone()); cs } /// Fill, content and stroke of a leaf item (no opacity, blend or effects). - pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) { + pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option) { let doc = f.doc; // Fill. if !it.fill.is_none() { @@ -575,7 +574,7 @@ impl Renderer { ctx.pop_layer(); } Content::Text(tf) => { - let cs = self.story(f, tf.story, page_name); + let cs = self.story(f, tf.story, page); if let Some(ft) = cs.frame(it.id) { match &tf.options.path { Some(pt) => self.draw_path_text(ctx, f, &cs, ft, xf, bp, pt), diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index ffc9d6be4..96e3840ce 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -117,8 +117,8 @@ fn union(r: Option, b: Rect) -> Option { fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs { let mut runs: Vec<(u32, BezPath)> = Vec::new(); - let mut underlines = Vec::new(); - let mut strikes = Vec::new(); + let mut underlines: Vec<(String, f32, Rect)> = Vec::new(); + let mut strikes: Vec<(String, f32, Rect)> = Vec::new(); let mut glyphs = 0; for g in &l.glyphs { if !g.visible { @@ -127,7 +127,21 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line let style = &cs.styles[g.style as usize]; for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] { if on { - bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g)))); + let r = rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g)); + // One bar per stretch, as the PDF export draws them: a bar per glyph left + // anti-aliased seams between the letters. + match bars.last_mut() { + Some((color, tint, last)) + if *color == rule.color + && *tint == rule.tint + && (last.y0 - r.y0).abs() < 1e-6 + && (last.y1 - r.y1).abs() < 1e-6 + && (r.x0 - last.x1).abs() < 0.5 => + { + last.x1 = last.x1.max(r.x1); + } + _ => bars.push((rule.color.clone(), rule.tint, r)), + } } } let outline = db.outline(&g.face, g.gid); diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index cfd08eea7..b84577eae 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -99,6 +99,7 @@ pub fn import(bytes: &[u8]) -> Result { let chars: Vec = text.chars().collect(); // Paragraphs: (style, runs of (text, format)). let mut paras: Vec<(String, Vec<(String, CharFormat)>)> = vec![(String::new(), Vec::new())]; + let mut started = false; let mut fmt = CharFormat::default(); let mut para_styles: Vec = Vec::new(); let mut char_styles: Vec = Vec::new(); @@ -151,6 +152,7 @@ pub fn import(bytes: &[u8]) -> Result { } } "ParaStyle" => { + started = true; let v = value.trim().to_string(); if !v.is_empty() && !para_styles.contains(&v) { para_styles.push(v.clone()); @@ -187,7 +189,7 @@ pub fn import(bytes: &[u8]) -> Result { } let style = paras.last().map(|p| p.0.clone()).unwrap_or_default(); // Header lines before any text don't start paragraphs. - if paras.len() == 1 && paras[0].1.is_empty() { + if !started && paras.len() == 1 && paras.first().is_some_and(|p| p.1.is_empty()) { i += 1; continue; } @@ -243,6 +245,18 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + #[test] + fn tagged_text_keeps_a_leading_empty_paragraph() { + let doc = Document::new(&designcraft_doc::build::NewDocument::default()); + let mut story = Story::new(StoryId(0)); + story.insert(0, "\nText"); + story.paras[0].style = "Head".into(); + story.paras[1].style = "Body".into(); + let back = import(tagged_text(&doc, &story).as_bytes()).unwrap(); + assert_eq!(back.story.text, story.text); + assert_eq!(back.story.paras, story.paras); + } + #[test] fn tagged_text_round_trips() { let src = "\r\n\r\nCaf<0x00E9> \\<1\\>\r\nA word and big text"; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index cdeaee792..8dbc262ba 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -348,6 +348,10 @@ pub enum Cursor { ResizeNeSw, Rotate, Pen, + /// The Pen over an open path's end point: a click continues the path. + PenContinue, + /// Over a type-on-a-path bracket: dragging it moves where the text starts or ends. + PathBracket, Text, Hand, HandGrab, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 3c75e51cd..21e90f734 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -1,8 +1,9 @@ //! Pen tool (P): click for corner points, drag for smooth points, click the first point to close, //! Enter/Esc (or switching tools) to finish an open path. Each anchor is its own undo step, as in -//! InDesign. +//! InDesign. Clicking an end point of an open path (the cursor shows it) continues that path from +//! there; dragging from it first reshapes its direction lines. -use designcraft_doc::SpreadRef; +use designcraft_doc::{ItemId, SpreadRef}; use designcraft_geom::{BezPath, Point}; use serde_json::{Value, json}; @@ -19,6 +20,45 @@ pub struct PenTool { hover: Point, /// Smart guides in canvas coordinates, as `snap_point` returned them. guides: Vec, + /// Continuing an existing path: the subpath the new anchors go on. + subpath: Option, + /// The press was on the continued path's end point: a drag reshapes its direction lines + /// instead of adding an anchor. + editing_end: bool, + /// The press on the end point has been dragged. + end_moved: bool, +} + +/// An end point of an open path under canvas point `p`: (item, subpath, whether it is the last +/// anchor). The selection is checked first, then the frontmost item under the pointer. +fn endpoint_at(cx: &ToolContext, p: Point) -> Option<(ItemId, usize, bool)> { + let tol = cx.tol(6.0); + let mut ids = cx.selection.items.clone(); + if let Some((_, id)) = cx.hit(p) + && !ids.contains(&id) + { + ids.push(id); + } + for id in ids { + let Some(it) = cx.doc.item(id) else { continue }; + if it.locked || it.is_group() || !matches!(it.content, designcraft_doc::Content::Unassigned) { + continue; + } + let Some(xf) = cx.item_canvas_xf(id).map(|m| m * it.xf) else { continue }; + for (si, sp) in it.path.subpaths.iter().enumerate() { + if sp.closed { + continue; + } + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue }; + if (xf * last.p - p).hypot() <= tol { + return Some((id, si, true)); + } + if (xf * first.p - p).hypot() <= tol { + return Some((id, si, false)); + } + } + } + None } fn anchor_json(a: &(Point, Point, Point)) -> Value { @@ -32,9 +72,35 @@ impl PenTool { self.anchors.clear(); self.dragging = false; self.guides.clear(); + self.subpath = None; + self.editing_end = false; + self.end_moved = false; vec![] } + /// Start continuing subpath `si` of `id` from its last (`at_end`) or first anchor: the anchors + /// so far are its other end and the end point, in spread coordinates. + fn continue_from(&mut self, cx: &ToolContext, id: ItemId, si: usize, at_end: bool) -> Vec { + let (Some(it), Some(loc)) = (cx.doc.item(id), cx.doc.find(id)) else { return vec![] }; + let Some(sp) = it.path.subpaths.get(si) else { return vec![] }; + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { return vec![] }; + let xf = cx.doc.parent_xf(&loc) * it.xf; + let at = |a: &designcraft_geom::Anchor, flip: bool| { + let (i, o) = if flip { (a.h_out, a.h_in) } else { (a.h_in, a.h_out) }; + (xf * a.p, xf * i, xf * o) + }; + // From the first anchor the path is reversed first, so the new anchors follow on. + let (other, end) = if at_end { (at(first, false), at(last, false)) } else { (at(last, true), at(first, true)) }; + self.path = Some(id.0); + self.subpath = Some(si); + self.spread = Some(loc.spread); + self.anchors = vec![other, end]; + self.editing_end = true; + self.end_moved = false; + self.dragging = true; + if at_end { vec![] } else { vec![Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))] } + } + /// Shift constrains first. The stored point is the snapped spread point. Guides are already canvas. fn snap_click(&mut self, cx: &ToolContext, spread: SpreadRef, mut p: Point, shift: bool) -> Point { if shift && let Some(last) = self.anchors.last() { @@ -53,7 +119,13 @@ impl PenTool { let anchors: Vec = self.anchors.iter().map(anchor_json).collect(); vec![Action::Exec("path.create".into(), json!({"spread": spread_json(sr), "anchors": anchors, "closed": false, "notifyPen": true}))] } - Some(id) => vec![Action::Exec("path.appendAnchor".into(), json!({"id": id, "anchor": anchor_json(last)}))], + Some(id) => { + let mut p = json!({"id": id, "anchor": anchor_json(last)}); + if let Some(si) = self.subpath { + p["subpath"] = json!(si); + } + vec![Action::Exec("path.appendAnchor".into(), p)] + } None => vec![], } } @@ -93,12 +165,24 @@ impl Tool for PenTool { && (first.0 - sp).hypot() <= cx.tol(6.0) { let out = match self.path { - Some(id) => vec![Action::Exec("path.close".into(), json!({"id": id}))], + Some(id) => { + let mut p = json!({"id": id}); + if let Some(si) = self.subpath { + p["subpath"] = json!(si); + } + vec![Action::Exec("path.close".into(), p)] + } None => vec![], }; self.finish(); return out; } + // On an open path's end point: continue that path. + if self.anchors.is_empty() + && let Some((id, si, at_end)) = endpoint_at(cx, ev.pos) + { + return self.continue_from(cx, id, si, at_end); + } let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift); if self.anchors.is_empty() { self.spread = Some(sr); @@ -108,6 +192,7 @@ impl Tool for PenTool { vec![] } PointerKind::Drag => { + self.end_moved |= self.editing_end; if let Some(a) = self.anchors.last_mut() { // Smooth point: out handle follows the pointer, in handle mirrors it. a.2 = sp; @@ -120,6 +205,17 @@ impl Tool for PenTool { return vec![]; } self.dragging = false; + // The continued path's end point: a drag set its direction lines. + if std::mem::take(&mut self.editing_end) { + let (Some(id), Some(si), Some(&(p, h_in, h_out))) = (self.path, self.subpath, self.anchors.last()) else { return vec![] }; + if !std::mem::take(&mut self.end_moved) || (h_out - p).hypot() < 1e-9 { + return vec![]; + } + return vec![Action::Exec( + "path.setAnchor".into(), + json!({"id": id, "subpath": si, "anchor": "last", "in": [h_in.x, h_in.y], "out": [h_out.x, h_out.y]}), + )]; + } self.commit_anchor() } _ => vec![], @@ -165,8 +261,8 @@ impl Tool for PenTool { out } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Pen + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if self.anchors.is_empty() && endpoint_at(cx, p).is_some() { Cursor::PenContinue } else { Cursor::Pen } } fn busy(&self) -> bool { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index c80c75973..b2ac97731 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -45,6 +45,11 @@ enum Drag { start: Point, cur: Point, }, + /// Dragging a type-on-a-path bracket along its path (`end`: the end bracket). + Bracket { + id: ItemId, + end: bool, + }, Anchor { id: u64, si: usize, @@ -58,6 +63,50 @@ enum Drag { }, } +/// Half the length of a path-type bracket, in screen pixels. +const BRACKET_PX: f64 = 9.0; + +/// A selected type-on-a-path item's path (inner space), its inner → canvas transform and its +/// brackets' arc lengths (start, end). +fn path_type_of(cx: &ToolContext, id: ItemId) -> Option<(designcraft_geom::warp::PathWarp, designcraft_geom::Affine, f64, f64)> { + let it = cx.doc.item(id)?; + let pt = it.text_frame()?.options.path.as_ref()?; + let warp = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), pt.flip); + let end = pt.end.unwrap_or(warp.length()).min(warp.length()); + Some((warp, cx.item_canvas_xf(id)? * it.xf, pt.start, end)) +} + +/// The bracket line at arc length `s`: across the path, `BRACKET_PX` either side (canvas). +fn bracket_line(cx: &ToolContext, warp: &designcraft_geom::warp::PathWarp, xf: designcraft_geom::Affine, s: f64) -> Option<(Point, Point)> { + let (p, a) = warp.at(s)?; + let c = xf * p; + let t = xf * (p + designcraft_geom::Vec2::new(a.cos(), a.sin())) - c; + let n = designcraft_geom::Vec2::new(-t.y, t.x); + let n = n / n.hypot().max(1e-9) * cx.tol(BRACKET_PX); + Some((c - n, c + n)) +} + +/// The bracket of a selected type-on-a-path item under `p` (canvas): (item, whether it is the end +/// bracket). +fn bracket_at(cx: &ToolContext, p: Point) -> Option<(ItemId, bool)> { + let tol = cx.tol(BRACKET_PX); + cx.selection.items.iter().find_map(|id| { + let (warp, xf, start, end) = path_type_of(cx, *id)?; + if cx.doc.item(*id).is_some_and(|it| it.locked) { + return None; + } + let near = |s: f64| warp.at(s).is_some_and(|(q, _)| (xf * q - p).hypot() <= tol); + // The end bracket first: with both brackets at one point, it can still be pulled back. + if near(end) { + Some((*id, true)) + } else if near(start) { + Some((*id, false)) + } else { + None + } + }) +} + /// Anchor or handle of a selected item under `p` (canvas): (item, subpath, anchor, handle). pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> { anchor_at_in(cx, &cx.selection.items, p) @@ -444,6 +493,13 @@ impl Tool for SelectionTool { } PointerKind::Down => { self.shift_release = None; + // Type on a path: a bracket moves where the text starts or ends. + if !ev.mods.shift + && let Some((id, end)) = bracket_at(cx, p) + { + self.drag = Drag::Bracket { id, end }; + return vec![Action::Begin("Move Bracket".into())]; + } if self.direct && let Some((id, si, ai, handle)) = anchor_at(cx, p) { @@ -503,6 +559,14 @@ impl Tool for SelectionTool { } } PointerKind::Drag => match self.drag.clone() { + Drag::Bracket { id, end } => { + let Some((warp, xf, _, _)) = path_type_of(cx, id) else { return vec![] }; + if xf.determinant().abs() < 1e-12 { + return vec![]; + } + let s = warp.nearest(xf.inverse() * p); + vec![Action::Preview("type.pathOptions".into(), json!({"id": id.0, if end { "end" } else { "start" }: s}))] + } Drag::Pending { start, hit } => { if (p - start).hypot() < cx.tol(3.0) { return vec![]; @@ -749,7 +813,9 @@ impl Tool for SelectionTool { let release = self.shift_release.take(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { - Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } => vec![Action::Commit], + Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } | Drag::Bracket { .. } => { + vec![Action::Commit] + } Drag::Pending { .. } => match release { Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))], None => vec![], @@ -808,11 +874,15 @@ impl Tool for SelectionTool { } Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), + Drag::None | Drag::Bracket { .. } => brackets(cx), _ => vec![], } } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if matches!(self.drag, Drag::Bracket { .. }) || (matches!(self.drag, Drag::None) && bracket_at(cx, p).is_some()) { + return Cursor::PathBracket; + } if self.direct { return Cursor::ArrowHollow; } @@ -837,6 +907,27 @@ impl Tool for SelectionTool { } } +/// The start and end brackets of the selected type-on-a-path items, in the selection colour: a +/// line across the path with a short tick pointing at the text between them. +fn brackets(cx: &ToolContext) -> Vec { + let mut out = Vec::new(); + for id in &cx.selection.items { + let Some((warp, xf, start, end)) = path_type_of(cx, *id) else { continue }; + let color = cx.doc.item(*id).and_then(|it| cx.doc.layer(it.layer)).map_or([43, 155, 255], |l| l.color); + for (s, towards) in [(start, end.max(start + 1.0)), (end, start.min(end - 1.0))] { + let Some((a, b)) = bracket_line(cx, &warp, xf, s) else { continue }; + out.push(Overlay::Line { a, b, color, dashed: false }); + // The tick at the outer end points along the path towards the text. + if let (Some((p0, _)), Some((p1, _))) = (warp.at(s), warp.at(towards)) { + let dir = xf * p1 - xf * p0; + let dir = dir / dir.hypot().max(1e-9) * cx.tol(BRACKET_PX * 0.6); + out.push(Overlay::Line { a: b, b: b + dir, color, dashed: false }); + } + } + } + out +} + fn handle_cursor(h: usize) -> Cursor { match h { 0 | 4 => Cursor::ResizeNwSe, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 213ccc68c..6df86485a 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -988,3 +988,174 @@ fn clicks_in_a_row_edit_and_select_text() { // Moving a little while pressed keeps the unit selected. assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 2.0, y)).is_empty()); } + +#[test] +fn pen_continues_an_open_path_from_either_end_point() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let anchors = vec![ + designcraft_geom::Anchor::corner(Point::new(100.0, 100.0)), + designcraft_geom::Anchor { + p: Point::new(200.0, 100.0), + h_in: Point::new(170.0, 80.0), + h_out: Point::new(230.0, 120.0), + kind: designcraft_geom::AnchorKind::Smooth, + }, + ]; + let mut it = Item::new(id, lid, Shape::Path, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, false))); + it.stroke = Stroke::default(); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| (x + off.x, y + off.y); + let ev = |k: PointerKind, (x, y): (f64, f64)| PointerEvent::new(k, x, y); + let mut t = create("pen"); + // The cursor says the end point continues the path; elsewhere it's the plain pen. + assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::PenContinue); + assert_eq!(t.cursor(&cx, Point::new(at(150.0, 300.0).0, at(150.0, 300.0).1), Mods::default()), Cursor::Pen); + // Clicking the last anchor adds nothing yet; the next click appends to that path. + assert!(t.pointer(&cx, &ev(PointerKind::Down, at(200.0, 100.0))).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, at(200.0, 100.0))).is_empty(), "a click on the end point changes nothing"); + t.pointer(&cx, &ev(PointerKind::Down, at(300.0, 200.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, at(300.0, 200.0))); + let [Action::Exec(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!((cmd.as_str(), p["id"].as_u64(), p["subpath"].as_u64()), ("path.appendAnchor", Some(id.0), Some(0))); + assert_eq!((p["anchor"]["p"][0].as_f64(), p["anchor"]["p"][1].as_f64()), (Some(300.0), Some(200.0))); + // Clicking the first anchor of the other end closes it. + let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + assert_eq!(a, vec![Action::Exec("path.close".into(), serde_json::json!({"id": id.0, "subpath": 0}))]); + assert!(t.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))).is_empty()); + // From the first anchor, the path is reversed first; a drag there sets its direction lines. + let mut t = create("pen"); + let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + assert_eq!(a, vec![Action::Exec("path.reverse".into(), serde_json::json!({"ids": [id.0], "subpath": 0}))]); + t.pointer(&cx, &ev(PointerKind::Drag, at(80.0, 140.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, at(80.0, 140.0))); + assert_eq!( + a, + vec![Action::Exec( + "path.setAnchor".into(), + serde_json::json!({"id": id.0, "subpath": 0, "anchor": "last", "in": [120.0, 60.0], "out": [80.0, 140.0]}) + )] + ); + // Closed paths and locked paths aren't continued. + let mut d2 = d.clone(); + d2.item_mut(id).unwrap().locked = true; + let cx = ctx(&d2, &s, &c, &l); + let t = create("pen"); + assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::Pen); +} + +#[test] +fn the_selection_tool_drags_a_path_type_bracket_along_its_path() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let line = designcraft_geom::PathData::single(designcraft_geom::SubPath::new( + vec![designcraft_geom::Anchor::corner(Point::new(100.0, 300.0)), designcraft_geom::Anchor::corner(Point::new(500.0, 300.0))], + false, + )); + let mut it = Item::new(id, lid, Shape::GraphicLine, line); + let sid = designcraft_doc::StoryId(d.alloc()); + let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() }; + tf.options.path = Some(designcraft_doc::PathType { start: 40.0, end: Some(300.0), ..Default::default() }); + it.content = designcraft_doc::Content::Text(tf); + let mut st = designcraft_doc::Story::with_text(sid, "On the line", Default::default()); + st.frames = vec![id]; + d.stories.insert(sid, Arc::new(st)); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y); + let ev = |k: PointerKind, p: Point| PointerEvent::new(k, p.x, p.y); + // Not selected: no brackets. + let none = Selection::default(); + assert!(create("selection").overlays(&ctx(&d, &none, &c, &l)).is_empty()); + let s = Selection { items: vec![id], ..Default::default() }; + let cx = ctx(&d, &s, &c, &l); + let mut t = create("selection"); + // Both brackets drawn across the path at 140 and 400 (a line and a tick each). + let o = t.overlays(&cx); + let xs: Vec = o.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { (a.x == b.x).then_some(a.x - off.x) } else { None }).collect(); + assert_eq!(xs, [140.0, 400.0], "{o:?}"); + assert_eq!(t.cursor(&cx, at(140.0, 300.0), Mods::default()), Cursor::PathBracket); + assert_ne!(t.cursor(&cx, at(250.0, 300.0), Mods::default()), Cursor::PathBracket); + // Drag the start bracket: its distance along the path follows the pointer, off the path too. + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, at(140.0, 301.0))), vec![Action::Begin("Move Bracket".into())]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, at(220.0, 330.0))); + let [Action::Preview(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!((cmd.as_str(), p["id"].as_u64()), ("type.pathOptions", Some(id.0))); + assert!((p["start"].as_f64().unwrap() - 120.0).abs() < 1e-6, "{p}"); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, at(220.0, 330.0))), vec![Action::Commit]); + // The end bracket. + t.pointer(&cx, &ev(PointerKind::Down, at(400.0, 300.0))); + let a = t.pointer(&cx, &ev(PointerKind::Drag, at(450.0, 290.0))); + let [Action::Preview(_, p)] = a.as_slice() else { panic!("{a:?}") }; + assert!((p["end"].as_f64().unwrap() - 350.0).abs() < 1e-6, "{p}"); +} + +#[test] +fn eyedropper_samples_a_placed_image_and_matches_other_objects() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let photo = ItemId(d.alloc()); + let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + asset: designcraft_doc::AssetId(1), + size: (100.0, 100.0), + xf: designcraft_geom::Affine::translate((0.0, 0.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + d.assets.insert( + designcraft_doc::AssetId(1), + std::sync::Arc::new(designcraft_doc::Asset { + id: designcraft_doc::AssetId(1), + name: "photo.png".into(), + mime: "image/png".into(), + link: None, + data: std::sync::Arc::new(vec![]), + pixels: Some((100, 100)), + page: 0, + }), + ); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + // An unfilled text frame over part of the photo lets it show through. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "x", ParaFormat::default()).unwrap(); + let swatch = add_rect(&mut d, Rect::new(300.0, 100.0, 350.0, 150.0)); + d.item_mut(swatch).unwrap().fill.swatch = "[Black]".into(); + let target = add_rect(&mut d, Rect::new(400.0, 100.0, 450.0, 150.0)); + // The photo's layer is locked: sampling it still works. + d.layers.iter_mut().for_each(|l| l.locked = true); + let s = Selection { items: vec![target], ..Default::default() }; + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("eyedropper"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + let sample = |x: f64, y: f64| Action::Exec("color.sample".into(), serde_json::json!({"at": [x, y], "spread": {"kind": "doc", "index": 0}})); + assert_eq!(a, vec![sample(120.0, 130.0)]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 180.0 + off.x, 180.0 + off.y)); + assert_eq!(a, vec![sample(180.0, 180.0)], "through the text frame"); + // A filled object is matched as before (here on a locked layer: not reachable, so nothing). + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)).is_empty()); + let mut d2 = d.clone(); + d2.layers.iter_mut().for_each(|l| l.locked = false); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]); + // Vector graphics and missing assets must retain the ordinary attribute-matching path. + for mime in ["application/pdf", "application/postscript", "image/svg+xml", "application/octet-stream"] { + std::sync::Arc::make_mut(d2.assets.get_mut(&designcraft_doc::AssetId(1)).unwrap()).mime = mime.into(); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))], "{mime}"); + } + d2.assets.clear(); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))]); +} diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index fbb1df9b8..e0bcd24f3 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -1,6 +1,7 @@ //! Rotate (R), Scale (S) and Shear (O) tools: drag around the selection centre (Alt-click sets //! the reference point elsewhere in a later iteration); Eyedropper (I): click an object to apply -//! its attributes to the selection. +//! its attributes to the selection, or a placed image to colour the selection (text or objects) +//! with the image's colour under the pointer. use designcraft_doc::SpreadRef; use designcraft_geom::{Point, Rect}; @@ -247,6 +248,43 @@ fn positive_pad(v: f64) -> f64 { if v.is_finite() && v > 0.0 { v } else { 0.0 } } +/// The spread and spread point under `p` when a placed raster shows there. Other graphic formats +/// still block an image behind them, but use attribute matching instead of pixel sampling. +fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { + use designcraft_doc::{Content, Item}; + fn shows(d: &designcraft_doc::Document, it: &Item, outer: designcraft_geom::Affine, at: Point, depth: usize) -> Option { + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + if let Content::Group { items } = &it.content { + return items.iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1)); + } + let graphic = matches!(it.content, Content::Graphic(_)); + // Unfilled frames (a text frame over the photo) let the image show through. + if !graphic && it.fill.is_none() { + return None; + } + if !designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0) { + return None; + } + Some(match &it.content { + Content::Graphic(g) => d.assets.get(&g.asset).is_some_and(|a| a.is_raster()), + _ => false, + }) + } + let (sr, at) = cx.layout.spread_at(p)?; + let sp = cx.doc.spread(sr)?; + let mut items: Vec<&Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| cx.doc.layer_rank(i.layer)); + let image = items + .into_iter() + .rev() + .filter(|it| cx.doc.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| shows(cx.doc, it, designcraft_geom::Affine::IDENTITY, at, 0))?; + image.then_some((sr, at)) +} + #[derive(Default)] pub struct EyedropperTool; @@ -258,8 +296,13 @@ impl Tool for EyedropperTool { if ev.kind != PointerKind::Down { return vec![]; } + let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some(); + // A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing). + if selected && let Some((sr, at)) = image_under(cx, ev.pos) { + return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))]; + } let Some((_, id)) = cx.hit(ev.pos) else { return vec![] }; - if cx.selection.items.is_empty() && cx.selection.text.is_none() { + if !selected { return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0]}))]; } vec![Action::Exec("object.matchAttributes".into(), json!({"from": id.0}))] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 850902196..76b5f89b2 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -29,6 +29,7 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } designcraft-idml = { workspace = true } zip = { workspace = true } # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 181b947de..b2ce3172a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -393,7 +393,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeNwSe => C::ResizeNwSe, Cursor::ResizeNeSw => C::ResizeNeSw, Cursor::Rotate => C::Alias, - Cursor::Pen => C::Crosshair, + Cursor::Pen | Cursor::PenContinue => C::Crosshair, + Cursor::PathBracket => C::ResizeColumn, Cursor::Text => C::Text, Cursor::Hand => C::Grab, Cursor::HandGrab => C::Grabbing, @@ -1017,6 +1018,28 @@ fn draw_text_selection( if range.is_empty() { continue; } + // Type on a path: the highlight bends with the text. + if let Some(map) = compose::PathTextMap::new(it, ft) { + for l in &ft.lines { + let (s, e) = (range.start.max(l.range.start), range.end.min(l.range.end)); + if s >= e { + continue; + } + for q in compose::highlight_quads(l, s, e, false) { + let (x0, x1) = (q[0].x.min(q[1].x), q[0].x.max(q[1].x)); + let (y0, y1) = (q[0].y.min(q[2].y), q[0].y.max(q[2].y)); + // Short slices, each turned with the path. + let n = ((x1 - x0) / 2.0).ceil().clamp(1.0, 2000.0) as usize; + for k in 0..n { + let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64); + let poly = + [Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p))); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + } + } + } + continue; + } let mut quads = Vec::new(); let mut glyphs = Vec::new(); for (li, l) in ft.lines.iter().enumerate() { @@ -1057,8 +1080,10 @@ fn draw_text_selection( let m = a * doc.text_xf(it); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { - let p0 = xf.to_screen(m * Point::new(x, bl - asc)); - let p1 = xf.to_screen(m * Point::new(x, bl + desc)); + // Type on a path: the caret stands across the path where it is along it. + let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p)); + let p0 = xf.to_screen(m * on(Point::new(x, bl - asc))); + let p1 = xf.to_screen(m * on(Point::new(x, bl + desc))); painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK)); } painter.ctx().request_repaint_after(std::time::Duration::from_millis(330)); @@ -1900,6 +1925,86 @@ mod tests { } } + /// A text frame holding `text`, the Type tool, and a harness stepping at 60 fps (clicks in a + /// row must land within the double-click delay). + fn typing(text: &str) -> (Harness<'static, DesignApp>, u64) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": text})).unwrap(); + let id = r["id"].as_u64().unwrap(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, id) + } + + /// The screen point over the first line of frame `id` where a click puts the caret at a byte + /// in `bytes`. + fn screen_at(h: &mut Harness<'_, DesignApp>, id: u64, bytes: std::ops::Range) -> Pos2 { + let mut x = 74.0; + let p = loop { + assert!(x < 400.0, "no point on the first line hits {bytes:?}"); + h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [x, 80.0]})).unwrap(); + if h.state().session.doc().unwrap().selection.text.is_some_and(|t| bytes.contains(&t.focus)) { + break Point::new(x, 80.0); + } + x += 1.0; + }; + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + let app = h.state(); + let c = app.session.layout().to_canvas(SpreadRef::Doc(0), p); + Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(c) + } + + fn click(h: &mut Harness<'_, DesignApp>, p: Pos2) { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + } + + fn selected(h: &Harness<'_, DesignApp>) -> std::ops::Range { + h.state().session.doc().unwrap().selection.text.map(|t| t.range()).unwrap_or_default() + } + + #[test] + fn clicks_in_a_row_select_word_line_paragraph_with_return_and_story() { + let text = "One two. Three four! Five six.\nSecond para here."; + let (mut h, id) = typing(text); + // Inside "Three": double-click selects the word, then line and paragraph with return. + let p = screen_at(&mut h, id, 10..14); + h.hover_at(p); + h.step(); + let mut got = vec![]; + for _ in 0..5 { + click(&mut h, p); + let r = selected(&h); + got.push(text[r].to_string()); + } + assert_eq!(got[1..], ["Three", "One two. Three four! Five six.", "One two. Three four! Five six.\n", text], "{got:?}"); + assert!(got[0].is_empty(), "one click places the caret: {got:?}"); + } + + #[test] + fn slow_clicks_do_not_add_up() { + let (mut h, id) = typing("One two. Three four!"); + let p = screen_at(&mut h, id, 10..14); + h.hover_at(p); + h.step(); + for _ in 0..3 { + click(&mut h, p); + h.run_steps(30); + } + assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h)); + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2896fcdc4..c2f1cb48e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -489,9 +489,17 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The widest a document tab grows: a longer name is shortened so its close button and the +/// tabs after it stay in view. +const DOC_TAB_MAX_W: f32 = 260.0; +/// Close button and padding around a tab's title. +const DOC_TAB_PAD: f32 = 44.0; + pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); + let max_w = DOC_TAB_MAX_W.min(bar.width()); + let close_label = crate::i18n::tr(&app.ui.language, "Close"); ui.painter().rect_filled(bar, 0.0, t.tab_strip); ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.border)); let mut x = bar.min.x; @@ -504,14 +512,28 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { let is_active = Some(i) == active; // `*` = unsaved; the view-mode suffix only in Preview (InDesign shows its GPU mode there). let suffix = if is_active && app.ui.screen_mode == crate::ScreenMode::Preview { " [Preview]" } else { "" }; - let label = format!("{}{} @ {:.0}%{suffix}", if *dirty { "*" } else { "" }, crate::rtl::isolate(title), zoom * 100.0); - let galley = crate::rtl::plain(ui.ctx(), &label, semibold(11.5), if is_active { t.text_strong } else { t.text_dim }); - let w = (galley.size().x + 44.0).max(if is_active { 210.0 } else { 150.0 }); + let color = if is_active { t.text_strong } else { t.text_dim }; + // The name gives way first; the dirty marker and zoom stay readable. + let zoom_label = crate::rtl::plain(ui.ctx(), &format!(" @ {:.0}%{suffix}", zoom * 100.0), semibold(11.5), color); + let name = format!("{}{}", if *dirty { "*" } else { "" }, crate::rtl::isolate(title)); + let name_max = max_w - DOC_TAB_PAD - zoom_label.size().x; + let galley = crate::rtl::truncated(ui.ctx(), &name, semibold(11.5), color, name_max); + let elided = galley.elided; + let w = (galley.size().x + zoom_label.size().x + DOC_TAB_PAD).max(if is_active { 210.0 } else { 150.0 }).min(max_w); let r = egui::Rect::from_min_size(egui::pos2(x, bar.min.y), vec2(w, 28.0)); let resp = ui.interact(r, ui.id().with(("doctab", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + let resp = if elided { + resp.on_hover_ui(|ui| { + let _ = crate::rtl::label(ui, title); + }) + } else { + resp + }; ui.painter().rect_filled(r, 0.0, if is_active { t.panel } else { t.tab_strip }); let cr = egui::Rect::from_center_size(egui::pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)); let cresp = ui.interact(cr, ui.id().with(("docclose", i)), Sense::click()); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{close_label} {title}"))); ui.painter().text( cr.center(), egui::Align2::CENTER_CENTER, @@ -519,7 +541,11 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { egui::FontId::proportional(14.0), if cresp.hovered() { t.text_strong } else { t.text_dim }, ); - ui.painter().galley(egui::pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + let name_w = galley.size().x; + // Clipped too, for a window narrower than the zoom label. + let text = ui.painter().with_clip_rect(r); + text.galley(egui::pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + text.galley(egui::pos2(r.min.x + 32.0 + name_w, r.center().y - zoom_label.size().y / 2.0), zoom_label, Color32::WHITE); ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); if cresp.clicked() { close = Some(i); @@ -783,6 +809,27 @@ mod tests { h.get_by_label(label).rect() } + /// A very long document name made its tab grow past the window, hiding the close button + /// and the tabs after it (#603). + #[test] + fn long_document_names_keep_tabs_short_and_close_buttons_visible() { + let latin = "Quarterly report with a very long file name ".repeat(6); + let hebrew = "דוח רבעוני עם שם קובץ ארוך מאוד ".repeat(6); + let mut app = app(); + for title in [latin.as_str(), hebrew.as_str(), "Short"] { + app.run("file.new", serde_json::json!({ "title": title })).unwrap(); + } + let h = test_window::open(app, vec2(1200.0, 600.0)); + for title in [latin.as_str(), hebrew.as_str()] { + let tab = rect(&h, title); + assert!(tab.width() <= super::DOC_TAB_MAX_W, "{title:?} tab is {} wide", tab.width()); + let close = rect(&h, &format!("Close {title}")); + assert!(tab.contains_rect(close), "close button {close:?} outside its tab {tab:?}"); + } + let (first, short) = (rect(&h, &latin), rect(&h, "Short")); + assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); + } + #[test] fn a_narrow_window_scrolls_the_control_bar() { let w = 700.0; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 44e3c0bda..15da79df8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -102,6 +102,7 @@ fn key_from(name: &str) -> Option { /// Handle one control request. Commands it runs leave the user's system clipboard alone. pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + app.install_clipboard_guard(ctx); let was = std::mem::replace(&mut app.in_control, true); let out = handle_request(app, ctx, req); app.in_control = was; @@ -180,21 +181,28 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd"), }; - app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); - app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); + app.control_synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); + app.control_synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); if let Some(t) = s("text") { - app.synthetic.push(egui::Event::Text(t.to_string())); + app.control_synthetic.push(egui::Event::Text(t.to_string())); } ctx.request_repaint(); ok(Value::Null) } "ui.move" => { let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32); - app.synthetic.push(egui::Event::PointerMoved(pos)); + app.control_synthetic.push(egui::Event::PointerMoved(pos)); ctx.request_repaint(); ok(Value::Null) } "ui.click" | "ui.drag" => { + let count = match p.get("count") { + None => 1, + Some(v) => match v.as_u64().filter(|n| (1..=255).contains(n)) { + Some(n) => n, + None => return err("`count` must be an integer from 1 through 255"), + }, + }; // Screen-space (egui points) pointer input through egui itself: reaches every widget. let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { @@ -212,21 +220,20 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest }; let a = egui::pos2(f("x"), f("y")); let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; - app.synthetic.push(egui::Event::PointerMoved(a)); - app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); + app.control_synthetic.push(egui::Event::PointerMoved(a)); + app.control_synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { // From the caller: a huge count would queue events until memory runs out. let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 10_000); for i in 1..=steps { let t = i as f32 / steps as f32; - app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); + app.control_synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); } } - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); + app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); for _ in 1..count { - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); + app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); + app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); } ctx.request_repaint(); ok(Value::Null) @@ -236,7 +243,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest // Focused widgets retain the synthetic keyboard route. Document automation uses // the explicit caret independently of the active tool, unlike ordinary typing. if ctx.text_edit_focused() || app.ui.dialog.is_some() || app.ui.palette.is_some() { - app.synthetic.push(egui::Event::Text(text.to_string())); + app.control_synthetic.push(egui::Event::Text(text.to_string())); ctx.request_repaint(); ok(Value::Null) } else { @@ -317,8 +324,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest } "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { - app.cancel_pdf_import(); - app.ui.dialog = None; + crate::dialogs::cancel(app); ok(Value::Null) } "ui.resize" => { @@ -368,6 +374,8 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), "app.export" => wrap(app.run("app.exportPng", p.clone())), "app.quit" => { + // Automation must not wait on a prompt: quit without asking about unsaved changes. + app.quit_without_asking = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Value::Null) } @@ -453,7 +461,7 @@ mod automation_contract_tests { } assert!(ctx.text_edit_focused()); assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"field"}))["ok"], true); - let events = std::mem::take(&mut app.synthetic); + let events = std::mem::take(&mut app.control_synthetic); frame(&mut app, &ctx, events); assert_eq!(app.ui.palette.as_deref(), Some("field")); assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello"); @@ -528,3 +536,79 @@ mod tests { assert!(text(&app).contains("SECRET")); } } + +#[cfg(test)] +mod deferred_clipboard_tests { + use super::*; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + #[test] + fn synthetic_paste_keeps_mock_clipboard_private() { + let reads = Arc::new(AtomicUsize::new(0)); + let observed = reads.clone(); + let mut app = DesignApp::new( + designcraft_engine::Session::new(), + crate::Services { + clipboard_text: Some(Box::new(move || { + observed.fetch_add(1, Ordering::SeqCst); + Some("SYNTHETIC_CLIPBOARD_SENTINEL".to_string()) + })), + ..Default::default() + }, + ); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"content":"text", "text":"Original", "rect":[72,72,200,100], "caret":false})).unwrap(); + app.run("selection.set", json!({"ids":[frame["id"]]})).unwrap(); + assert!(!app.session.wants_text()); + let ctx = egui::Context::default(); + let (req, _) = ControlRequest::new("ui.key", json!({"key":"V", "cmd":true})); + assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(v) if v["ok"]==true)); + assert!(!app.in_control); + for step in 0..3 { + let mut raw = egui::RawInput { time: Some(step as f64 * 0.05), ..Default::default() }; + app.raw_input_hook(&mut raw); + ctx.run_ui(raw, |ui| menus::shortcuts(&mut app, ui.ctx())).textures_delta.clear(); + } + let texts: Vec = app.session.active().unwrap().doc.stories.values().map(|story| story.text.clone()).collect(); + assert_eq!(reads.load(Ordering::SeqCst), 0, "synthetic command read mocked system clipboard; stories={texts:?}"); + assert!(!texts.iter().any(|text| text.contains("SYNTHETIC_CLIPBOARD_SENTINEL"))); + // Once control input is drained, an ordinary user paste still reads its clipboard. + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + app.run("edit.paste", json!({})).unwrap(); + assert_eq!(reads.load(Ordering::SeqCst), 1); + } + #[test] + fn deferred_control_widget_copy_leaves_native_clipboard_output_alone() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let (req, _) = ControlRequest::new("ui.move", json!({"x":10,"y":10})); + let _ = handle(&mut app, &ctx, &req); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + let mut private = ctx.run_ui(raw, |ui| ui.ctx().copy_text("control field".into())); + assert!(!private.platform_output.commands.iter().any(|cmd| matches!(cmd, egui::OutputCommand::CopyText(_)))); + private.textures_delta.clear(); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + let mut native = ctx.run_ui(raw, |ui| ui.ctx().copy_text("native field".into())); + assert!(native.platform_output.commands.iter().any(|cmd| matches!(cmd, egui::OutputCommand::CopyText(text) if text=="native field"))); + native.textures_delta.clear(); + } + + #[test] + fn control_click_rejects_unbounded_counts_before_queuing_input() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + for count in [json!(u64::MAX), json!(256), json!(0), json!(-1), json!(1.5)] { + let (req, _) = ControlRequest::new("ui.click", json!({"x":10,"y":10,"count":count})); + assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(v) if v["ok"]==false)); + assert!(app.control_synthetic.is_empty()); + assert!(app.synthetic.is_empty()); + assert!(!app.in_control); + } + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 66d179bbb..17a68e7af 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1656,6 +1656,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); }); } + STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { @@ -1733,14 +1734,20 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { Some(true) => { let _ = confirm(app); } - Some(false) => { - app.ui.dialog = None; - app.cancel_pdf_import(); - } + Some(false) => cancel(app), None => {} } } +/// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing +/// dialog puts the document back as it was. +pub fn cancel(app: &mut DesignApp) { + if app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { + app.session.cancel_interaction(); + } + app.cancel_pdf_import(); +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -2078,6 +2085,9 @@ pub fn confirm(app: &mut DesignApp) -> Result { "closeDocument" => { // The document may have moved in the tab row (or gone) while the dialog was open. let uid = d.fields.get("uid").and_then(Value::as_u64); + if d.b("quitting") { + return app.confirm_quit_document(uid.ok_or("Missing document in quit confirmation")?, d.b("discard")); + } let index = |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid); let Some(i) = index(app) else { return Ok(Value::Null) }; if !d.b("discard") { @@ -2164,6 +2174,20 @@ pub fn confirm(app: &mut DesignApp) -> Result { "alert" => Ok(Value::Null), "findChange" if d.b("objectMode") => Ok(Value::Null), "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), + STEP_AND_REPEAT => { + // The preview goes; the copies are made once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let r = step_and_repeat_params(app, &d).and_then(|p| app.run("edit.stepAndRepeat", p)); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } id if id.starts_with("cmd:") => { let cid = &id[4..]; let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params); @@ -3607,6 +3631,120 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } +const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +/// The params a previewing dialog shows on the page (its interaction is open while it is set). +const PREVIEWED: &str = "_previewed"; + +/// Edit › Step and Repeat, as in InDesign: Repeat (Count, or Rows and Columns with Create as a +/// grid), Offset (Vertical, Horizontal, in the document's ruler units) and Preview, which shows +/// the copies on the page while the dialog is open. +fn step_and_repeat_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units)); + for (k, v) in [ + ("count", json!("1")), + ("rows", json!("1")), + ("columns", json!("1")), + ("vertical", json!(format_measure(12.0, vu))), + ("horizontal", json!(format_measure(12.0, hu))), + ] { + d.fields.entry(k).or_insert(v); + } + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Repeat")).font(semibold(12.0))); + egui::Grid::new("sr_repeat").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + let grid = d.b("grid"); + let fields: &[(&str, &str)] = if grid { &[("rows", "Rows:"), ("columns", "Columns:")] } else { &[("count", "Count:")] }; + for (k, l) in fields { + crate::rtl::label(ui, tr(l)); + text_field(ui, d, k, 70.0); + ui.end_row(); + } + crate::rtl::label(ui, ""); + check(ui, d, "grid", &tr("Create as a grid")); + ui.end_row(); + }); + ui.add_space(10.0); + crate::rtl::label(ui, egui::RichText::new(tr("Offset")).font(semibold(12.0))); + ui.horizontal(|ui| { + for (k, l) in [("vertical", "Vertical:"), ("horizontal", "Horizontal:")] { + crate::rtl::label(ui, tr(l)); + text_field(ui, d, k, 80.0); + ui.add_space(12.0); + } + }); + }); + ui.add_space(32.0); + check(ui, d, "preview", &tr("Preview")); + }); + step_and_repeat_preview(app, d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + +/// `edit.stepAndRepeat`'s params from the dialog: whole counts, offsets in the ruler units +/// (or any unit typed: "-0.75 in", "3p"). Empty fields are 1 copy and 1p0. +fn step_and_repeat_params(app: &DesignApp, d: &Dialog) -> Result { + let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units)); + let invalid = |s: &str| format!("{}: {}", crate::i18n::tr(&app.ui.language, "Invalid value"), s.trim()); + // Fields not shown yet (a dialog confirmed over the control channel) take the defaults. + let whole = |k: &str| { + let s = d.s(k); + if s.trim().is_empty() { Ok(1) } else { s.trim().parse::().map_err(|_| invalid(&s)) } + }; + let measure = |k: &str, u: Unit| { + let s = d.s(k); + if s.trim().is_empty() { + return Ok(12.0); + } + parse_measure(&s, u).ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(&s)) + }; + let (dx, dy) = (measure("horizontal", hu)?, measure("vertical", vu)?); + Ok(if d.b("grid") { + json!({"rows": whole("rows")?, "columns": whole("columns")?, "dx": dx, "dy": dy}) + } else { + json!({"count": whole("count")?, "dx": dx, "dy": dy}) + }) +} + +/// Preview on: the copies show on the page (redone when a field changes); off: they go. +fn step_and_repeat_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match step_and_repeat_params(app, d) { + Ok(p) => p, + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Step and Repeat") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("edit.stepAndRepeat", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + /// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on /// the right) or a text anchor, with a cross-reference format. fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -3793,6 +3931,81 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + #[test] + fn step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 600, 540, 640]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let open = |app: &mut DesignApp| { + crate::menus::activate(app, "edit.stepAndRepeat", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(STEP_AND_REPEAT)); + }; + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let items = |app: &DesignApp| app.session.doc().unwrap().doc.spreads[0].items.len(); + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let (n0, u0) = (items(&app), undo(&app)); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + open(&mut app); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Step and Repeat", "Repeat", "Count:", "Create as a grid", "Offset", "Vertical:", "Horizontal:", "Preview", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + // Preview: the copies show, and change with the fields, without an undo step. + set(&mut app, "count", json!("5")); + set(&mut app, "vertical", json!("-0.75 in")); + set(&mut app, "horizontal", json!("0")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!((items(&app), undo(&app)), (n0 + 5, u0)); + set(&mut app, "count", json!("3")); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0 + 3); + set(&mut app, "preview", json!(false)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0, "Preview off takes them away"); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0 + 3); + // Cancel puts the document back. + cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!((items(&app), undo(&app)), (n0, u0)); + // OK after a preview makes the copies once, as one undo step, 0.75 in apart. + app.run("selection.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + set(&mut app, "count", json!("2")); + set(&mut app, "vertical", json!("-0.75 in")); + set(&mut app, "horizontal", json!("0")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let r = confirm(&mut app).unwrap(); + assert_eq!((items(&app), undo(&app)), (n0 + 2, u0 + 1)); + let last = r["ids"][1].as_u64().unwrap(); + let top = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(last)).unwrap().bounds().min_y(); + assert!((top - (600.0 - 108.0)).abs() < 1e-6, "{top}"); + // Create as a grid: Rows × Columns cells, the original being one. + app.run("selection.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + set(&mut app, "grid", json!(true)); + set(&mut app, "rows", json!("2")); + set(&mut app, "columns", json!("3")); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Rows:"); + visible_label(&labels, screen, "Columns:"); + assert_eq!(confirm(&mut app).unwrap()["created"], 5); + // A value that isn't a measure keeps the dialog open and says so. + open(&mut app); + set(&mut app, "vertical", json!("up")); + assert!(confirm(&mut app).is_err()); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.s("status"), "Invalid value: up"); + } + fn dialog_context(language: &str) -> egui::Context { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx, language); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 72810e151..0d581e992 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -90,7 +90,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { x += w; } let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0)); + let menu = ui.interact(menu_r, ui.id().with("dock-menu"), Sense::click()); + if menu.hovered() { + ui.painter().rect_filled(menu_r, 3.0, t.hover); + } icons::paint(ui.painter(), menu_r, "menu", t.icon); + let tab = app.ui.dock_tab.clone(); + egui::Popup::menu(&menu).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + if tab == "pages" { + panels::pages::panel_menu(app, ui); + ui.separator(); + } + crate::panel_docking::panel_menu(app, ui, &tab); + }); egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 10, top: 4, bottom: 6 }).show(ui, |ui| { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match app.ui.dock_tab.as_str() { "pages" => panels::pages::show(app, ui), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5ffa6275a..6d6503a00 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), + ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), ("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]), ("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]), @@ -646,6 +648,15 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Fill (X)", ["Fill (X)", "Fill (X)", "Fill (X)", "Fill (X)", "填色 (X)"]), ("Stroke (X)", ["Stroke (X)", "Stroke (X)", "Stroke (X)", "Stroke (X)", "描边 (X)"]), ("Apply None (/)", ["Apply None (/)", "Apply None (/)", "Apply None (/)", "Apply None (/)", "应用无 (/)"]), + ( + "Default Fill and Stroke", + ["Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"], + ), + ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]), + ( + "Toggle Fill and Stroke", + ["Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"], + ), ("Reverse the gradient", ["Reverse the gradient", "Reverse the gradient", "Reverse the gradient", "Reverse the gradient", "反转渐变"]), ("Style source", ["Style source", "Style source", "Style source", "Style source", "样式源"]), ("Open", ["Open", "Open", "Open", "Open", "打开"]), @@ -699,6 +710,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("for", ["for", "pour", "por", "に対して", "行"]), ("About DesignCraft", ["About DesignCraft", "About DesignCraft", "About DesignCraft", "About DesignCraft", "关于 DesignCraft"]), + ("Quit DesignCraft", ["DesignCraft beenden", "Quitter DesignCraft", "Salir de DesignCraft", "DesignCraft を終了", "退出 DesignCraft"]), ("Accept All Changes", ["Accept All Changes", "Accept All Changes", "Accept All Changes", "Accept All Changes", "接受所有更改"]), ("Activate Document", ["Activate Document", "Activate Document", "Activate Document", "Activate Document", "激活文档"]), ("Add Anchor Point", ["Add Anchor Point", "Add Anchor Point", "Add Anchor Point", "Add Anchor Point", "添加锚点"]), @@ -738,6 +750,22 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]), ("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]), + ("Repeat", ["Wiederholen", "Répéter", "Repetir", "繰り返し", "重复"]), + ("Count:", ["Anzahl:", "Nombre :", "Recuento:", "繰り返し数:", "计数:"]), + ("Create as a grid", ["Als Raster erstellen", "Créer en tant que grille", "Crear como cuadrícula", "グリッドとして作成", "创建为网格"]), + ("Rows:", ["Zeilen:", "Rangées :", "Filas:", "行:", "行:"]), + ("Columns:", ["Spalten:", "Colonnes :", "Columnas:", "列:", "列:"]), + ("Vertical:", ["Vertikal:", "Vertical :", "Vertical:", "垂直方向:", "垂直:"]), + ("Horizontal:", ["Horizontal:", "Horizontal :", "Horizontal:", "水平方向:", "水平:"]), + ("Invalid value", ["Ungültiger Wert", "Valeur non valide", "Valor no válido", "無効な値", "无效值"]), + ("Isolate Blending", ["Füllmethode isolieren", "Isoler le mode de fusion", "Aislar fusión", "描画モードを分離", "分离混合"]), + ("Knockout Group", ["Aussparungsgruppe", "Groupe de masquage", "Grupo de exclusión", "グループの抜き", "挖空组"]), + ("Object:", ["Objekt:", "Objet :", "Objeto:", "オブジェクト:", "对象:"]), + ("Group:", ["Gruppe:", "Groupe :", "Grupo:", "グループ:", "编组:"]), + ( + "Sample Color (Eyedropper)", + ["Farbe aufnehmen (Pipette)", "Prélever la couleur (Pipette)", "Tomar color (Cuentagotas)", "カラーを抽出 (スポイト)", "取样颜色 (吸管)"], + ), ("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]), ( "Apply Parent to Pages…", @@ -2828,6 +2856,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Paragraph Rules", ["Absatzlinien", "Filets de paragraphe", "Filetes de párrafo", "段落境界線", "段落线"]), ("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]), ("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]), + ("Slug", ["Infobereich", "Ligne-bloc", "Indicaciones", "印刷可能領域", "辅助信息区"]), + ("Presentation", ["Präsentation", "Présentation", "Presentación", "プレゼンテーション", "演示文稿"]), + ("Extras", ["Extras", "Options supplémentaires", "Extras", "エクストラ", "附加"]), ("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]), ]; diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 1496983a7..769511968 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Reverse Path", "عكس المسار"), + ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), ("Delete", "حذف"), ("Duplicate", "تكرار"), @@ -149,6 +151,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bookmarks", "إشارات مرجعية"), ("Show All Menu Items", "إظهار كل عناصر القائمة"), ("About DesignCraft", "حول DesignCraft"), + ("Quit DesignCraft", "إنهاء DesignCraft"), ("Welcome to DesignCraft", "مرحباً بك في DesignCraft"), ("Page layout for print and screen — fast, open and scriptable.", "تخطيط الصفحات للطباعة والشاشة — سريع ومفتوح وقابل للبرمجة."), (" New file ", " ملف جديد "), @@ -529,6 +532,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Superscript", "النص المرتفع"), ("Tag the selection", "إضافة وسم للتحديد"), ("The structure is valid.", "البنية صالحة."), + ("Toggle Fill and Stroke", "التبديل بين التعبئة والحد"), ("Toggle double column", "تبديل التخطيط بعمودين"), ("Type:", "النوع:"), ("Underline", "تسطير"), @@ -853,6 +857,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Crop to:", "القص إلى:"), ("Cross-Reference Format", "تنسيق المرجع المتقاطع"), ("Cursor Key:", "مفتاح المؤشر:"), + ("Default Fill and Stroke", "التعبئة والحد الافتراضيان"), ("Default Fill and Stroke (D)", "التعبئة والحد الافتراضيان (D)"), ("Default View:", "العرض الافتراضي:"), ("Delete Column", "حذف العمود"), @@ -994,6 +999,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Style Name Conflicts", "تعارض أسماء الأنماط"), ("Stylistic Sets", "المجموعات الأسلوبية"), ("Subdivisions:", "التقسيمات الفرعية:"), + ("Swap Fill and Stroke", "تبديل التعبئة والحد"), ("Swap Fill and Stroke (Shift+X)", "تبديل التعبئة والحد (Shift+X)"), ("Synchronize", "مزامنة"), ("System default", "افتراضي النظام"), @@ -1049,6 +1055,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"), ("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"), ("Apply Color", "تطبيق اللون"), + ("Repeat", "تكرار"), + ("Count:", "العدد:"), + ("Create as a grid", "إنشاء كشبكة"), + ("Rows:", "الصفوف:"), + ("Columns:", "الأعمدة:"), + ("Vertical:", "رأسي:"), + ("Horizontal:", "أفقي:"), + ("Invalid value", "قيمة غير صالحة"), + ("Isolate Blending", "عزل المزج"), + ("Knockout Group", "مجموعة مفرغة"), + ("Object:", "الكائن:"), + ("Group:", "المجموعة:"), + ("Sample Color (Eyedropper)", "أخذ عينة من اللون (القطارة)"), ("Apply None", "إزالة اللون"), ("Height Only", "الارتفاع فقط"), ("Width Only", "العرض فقط"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 6a6f06846..747511ae8 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Reverse Path", "Inverti tracciato"), + ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), ("Duplicate", "Duplica"), ("Select All", "Seleziona tutto"), @@ -376,6 +378,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "Riempimento (X)"), ("Stroke (X)", "Traccia (X)"), ("Apply None (/)", "Applica nessuno (/)"), + ("Default Fill and Stroke", "Riempimento e traccia predefiniti"), + ("Swap Fill and Stroke", "Scambia riempimento e traccia"), + ("Toggle Fill and Stroke", "Alterna riempimento e traccia"), ("Reverse the gradient", "Inverti la sfumatura"), ("Style source", "Origine stile"), ("Open", "Apri"), @@ -396,6 +401,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "Doppie pagine con al massimo 10 pagine"), ("for", "per"), ("About DesignCraft", "Informazioni su DesignCraft"), + ("Quit DesignCraft", "Esci da DesignCraft"), ("Accept All Changes", "Accetta tutte le modifiche"), ("Activate Document", "Attiva documento"), ("Add Anchor Point", "Aggiungi punto di ancoraggio"), @@ -411,6 +417,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"), ("Apply Cell Style", "Applica stile di cella"), ("Apply Color", "Applica colore"), + ("Repeat", "Ripeti"), + ("Count:", "Conteggio:"), + ("Create as a grid", "Crea come griglia"), + ("Rows:", "Righe:"), + ("Columns:", "Colonne:"), + ("Vertical:", "Verticale:"), + ("Horizontal:", "Orizzontale:"), + ("Invalid value", "Valore non valido"), + ("Isolate Blending", "Isola fusione"), + ("Knockout Group", "Gruppo di foratura"), + ("Object:", "Oggetto:"), + ("Group:", "Gruppo:"), + ("Sample Color (Eyedropper)", "Campiona colore (Contagocce)"), ("Apply Condition", "Applica condizione"), ("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"), ("ArtCraft App Page…", "Pagina dell'app ArtCraft…"), @@ -1493,4 +1512,5 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules", "Filetti paragrafo"), ("Rule Below", "Filetto sotto"), ("Tint:", "Tinta:"), + ("Presentation", "Presentazione"), ]; diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 85700ac95..263d4226a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Reverse Path", "パスの方向反転"), + ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), ("Duplicate", "複製"), ("Select All", "すべてを選択"), @@ -378,6 +380,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "塗り (X)"), ("Stroke (X)", "線 (X)"), ("Apply None (/)", "カラーなしを適用 (/)"), + ("Default Fill and Stroke", "塗りと線をデフォルトに戻す"), + ("Swap Fill and Stroke", "塗りと線を入れ替え"), + ("Toggle Fill and Stroke", "塗りと線を切り替え"), ("Reverse the gradient", "グラデーションを反転"), ("Style source", "スタイルの同期元"), ("Open", "開く"), @@ -398,6 +403,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "最大10ページのスプレッド"), ("for", "に対して"), ("About DesignCraft", "DesignCraft について"), + ("Quit DesignCraft", "DesignCraft を終了"), ("Accept All Changes", "すべての変更を採用"), ("Activate Document", "ドキュメントをアクティブ化"), ("Add Anchor Point", "アンカーポイントを追加"), @@ -413,6 +419,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"), ("Apply Cell Style", "セルスタイルを適用"), ("Apply Color", "カラーを適用"), + ("Repeat", "繰り返し"), + ("Count:", "繰り返し数:"), + ("Create as a grid", "グリッドとして作成"), + ("Rows:", "行:"), + ("Invalid value", "無効な値"), + ("Isolate Blending", "描画モードを分離"), + ("Knockout Group", "グループの抜き"), + ("Object:", "オブジェクト:"), + ("Group:", "グループ:"), + ("Sample Color (Eyedropper)", "カラーを抽出 (スポイト)"), ("Apply Condition", "条件を適用"), ("Apply Parent to Pages…", "ページに親ページを適用…"), ("ArtCraft App Page…", "ArtCraft アプリページ…"), @@ -1550,15 +1566,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Type/Insert Special Character/Quotation Marks", "書式/特殊文字を挿入/引用符"), ("Type/Insert Special Character/Symbols", "書式/特殊文字を挿入/記号"), ("Type/Insert White Space", "書式/空白文字を挿入"), - ("Type/Proof Setup", "書式/カラー校正設定"), - ("Type/Rotate Spread", "書式/スプレッドを回転"), - ("Type/Separations Preview", "書式/分版プレビュー"), ("Type/Story Direction", "書式/組み方向"), ("Type/Text Variables", "書式/テキスト変数"), ("Type/Track Changes", "書式/変更を記録"), ("Type/Type on a Path", "書式/パス上のテキスト"), ("View/Display Performance", "表示/表示品質"), + ("View/Extras", "表示/表示補助"), ("View/Grids & Guides", "表示/グリッドとガイド"), + ("View/Proof Setup", "表示/カラー校正設定"), + ("View/Rotate Spread", "表示/スプレッドを回転"), + ("View/Screen Mode", "表示/スクリーンモード"), + ("View/Separations Preview", "表示/分版プレビュー"), + ("View/Structure", "表示/構造"), ("Window/Arrange", "ウィンドウ/配置"), ("Window/Interactive", "ウィンドウ/インタラクティブ"), ("Window/Interface Color Theme", "ウィンドウ/インターフェイスのカラーテーマ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8c922de3c..3f955f68f 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Reverse Path", "Inverter demarcador"), + ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), ("Delete", "Excluir"), ("Duplicate", "Duplicar"), @@ -149,6 +151,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bookmarks", "Indicadores"), ("Show All Menu Items", "Mostrar todos os itens de menu"), ("About DesignCraft", "Sobre o DesignCraft"), + ("Quit DesignCraft", "Encerrar o DesignCraft"), ("Welcome to DesignCraft", "Bem-vindo ao DesignCraft"), ("Page layout for print and screen — fast, open and scriptable.", "Layout de página para impressão e tela — rápido, aberto e scriptável."), (" New file ", " Novo arquivo "), @@ -520,6 +523,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Superscript", "Sobrescrito"), ("Tag the selection", "Marcar a seleção"), ("The structure is valid.", "A estrutura é válida."), + ("Toggle Fill and Stroke", "Alternar entre preenchimento e traçado"), ("Toggle double column", "Alternar coluna dupla"), ("Type:", "Tipo:"), ("Underline", "Sublinhado"), @@ -847,6 +851,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Crop to:", "Cortar para:"), ("Cross-Reference Format", "Formato de referência cruzada"), ("Cursor Key:", "Tecla do cursor:"), + ("Default Fill and Stroke", "Preenchimento e traçado padrão"), ("Default Fill and Stroke (D)", "Preenchimento e traçado padrão (D)"), ("Default View:", "Exibição padrão:"), ("Delete Column", "Excluir coluna"), @@ -993,6 +998,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Style Name Conflicts", "Conflitos de nomes de estilos"), ("Stylistic Sets", "Conjuntos estilísticos"), ("Subdivisions:", "Subdivisões:"), + ("Swap Fill and Stroke", "Alternar preenchimento e traçado"), ("Swap Fill and Stroke (Shift+X)", "Alternar preenchimento e traçado (Shift+X)"), ("Synchronize", "Sincronizar"), ("System default", "Padrão do sistema"), @@ -1045,6 +1051,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "Formatação afeta o contêiner (J)"), ("Formatting affects text (J)", "Formatação afeta o texto (J)"), ("Apply Color", "Aplicar cor"), + ("Repeat", "Repetir"), + ("Count:", "Contagem:"), + ("Create as a grid", "Criar como grade"), + ("Rows:", "Linhas:"), + ("Columns:", "Colunas:"), + ("Vertical:", "Vertical:"), + ("Horizontal:", "Horizontal:"), + ("Invalid value", "Valor inválido"), + ("Isolate Blending", "Isolar mesclagem"), + ("Knockout Group", "Grupo de vazamento"), + ("Object:", "Objeto:"), + ("Group:", "Grupo:"), + ("Sample Color (Eyedropper)", "Amostrar cor (Conta-gotas)"), ("Apply None", "Aplicar nenhum"), ("Height Only", "Somente altura"), ("Width Only", "Somente largura"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index c4371387b..efccfc166 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Reverse Path", "Розвернути контур"), + ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), ("Duplicate", "Дублювати"), ("Select All", "Виділити все"), @@ -379,6 +381,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "Заливка (X)"), ("Stroke (X)", "Обведення (X)"), ("Apply None (/)", "Без кольору (/)"), + ("Default Fill and Stroke", "Заливка та обведення за замовчуванням"), + ("Swap Fill and Stroke", "Змінити заливку та обведення"), + ("Toggle Fill and Stroke", "Перемкнути заливку та обведення"), ("Reverse the gradient", "Змінити напрямок градієнта"), ("Style source", "Джерело стилів"), ("Open", "Відкрити"), @@ -399,6 +404,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "Розвороти до 10 сторінок"), ("for", "для"), ("About DesignCraft", "Про DesignCraft"), + ("Quit DesignCraft", "Завершити DesignCraft"), ("Accept All Changes", "Прийняти всі зміни"), ("Activate Document", "Активувати документ"), ("Add Anchor Point", "Додати опорну точку"), @@ -414,6 +420,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"), ("Apply Cell Style", "Застосувати стиль клітинки"), ("Apply Color", "Застосувати колір"), + ("Repeat", "Повторення"), + ("Count:", "Кількість:"), + ("Create as a grid", "Створити як сітку"), + ("Rows:", "Рядки:"), + ("Columns:", "Стовпці:"), + ("Vertical:", "Вертикально:"), + ("Horizontal:", "Горизонтально:"), + ("Invalid value", "Неприпустиме значення"), + ("Isolate Blending", "Ізолювати змішування"), + ("Knockout Group", "Група вибивання"), + ("Object:", "Об’єкт:"), + ("Group:", "Група:"), + ("Sample Color (Eyedropper)", "Узяти колір (піпетка)"), ("Apply Condition", "Застосувати умову"), ("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"), ("ArtCraft App Page…", "Сторінка програми ArtCraft…"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 69415e9b3..c93464374 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -178,6 +178,29 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&pts); pen.line(&[(13.0, 4.0), (15.5, 6.0), (16.5, 2.8)]); } + // Properties › Transform: rotate a quarter turn (an arc over a square), flip (a shape and its + // mirror image across a dashed axis). + "rotate-cw" | "rotate-ccw" => { + let m = |x: f32| if name == "rotate-ccw" { 20.0 - x } else { x }; + pen.rect(m(3.0).min(m(11.0)), 9.0, m(3.0).max(m(11.0)), 17.0); + let pts: Vec<(f32, f32)> = (0..12) + .map(|i| { + let a = std::f32::consts::PI * (1.0 - i as f32 / 11.0); + (m(11.0 + 5.0 * a.cos()), 9.0 - 5.0 * a.sin()) + }) + .collect(); + pen.line(&pts); + pen.line(&[(m(14.0), 7.0), (m(16.0), 9.5), (m(18.0), 7.0)]); + } + "flip-h" | "flip-v" => { + let t = |x: f32, y: f32| if name == "flip-v" { (y, x) } else { (x, y) }; + pen.fill(&[t(3.0, 16.0), t(8.5, 4.0), t(8.5, 16.0)]); + pen.closed(&[t(11.5, 16.0), t(11.5, 4.0), t(17.0, 16.0)]); + for k in 0..4 { + let y0 = 2.5 + k as f32 * 4.0; + pen.line(&[t(10.0, y0), t(10.0, y0 + 2.0)]); + } + } "tool-gradient" | "tool-gradient-feather" => { for i in 0..6 { let a = 30 + i * 35; @@ -495,6 +518,38 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { _ => {} } } + // Pathfinder: a back square (top left) and a front one (bottom right), the result filled. + "pf-add" | "pf-subtract" | "pf-intersect" | "pf-exclude" | "pf-minus-back" => { + let (back, front) = ((3.0, 3.0, 13.0, 13.0), (7.0, 7.0, 17.0, 17.0)); + match name { + "pf-add" => { + pen.frect(back.0, back.1, back.2, back.3); + pen.frect(front.0, front.1, front.2, front.3); + } + "pf-subtract" => { + pen.frect(3.0, 3.0, 13.0, 7.0); + pen.frect(3.0, 7.0, 7.0, 13.0); + pen.rect(front.0, front.1, front.2, front.3); + } + "pf-intersect" => { + pen.rect(back.0, back.1, back.2, back.3); + pen.rect(front.0, front.1, front.2, front.3); + pen.frect(7.0, 7.0, 13.0, 13.0); + } + "pf-exclude" => { + pen.frect(3.0, 3.0, 13.0, 7.0); + pen.frect(3.0, 7.0, 7.0, 13.0); + pen.frect(13.0, 7.0, 17.0, 17.0); + pen.frect(7.0, 13.0, 13.0, 17.0); + pen.rect(7.0, 7.0, 13.0, 13.0); + } + _ => { + pen.rect(back.0, back.1, back.2, back.3); + pen.frect(13.0, 7.0, 17.0, 17.0); + pen.frect(7.0, 13.0, 13.0, 17.0); + } + } + } // ---- Properties panel glyphs (original drawings) "orient-portrait" => { pen.rect(5.5, 3.0, 14.5, 17.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec52bd141..1db2f591d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -294,6 +294,10 @@ pub struct UiState { pub palette: Option, #[serde(skip)] pub flyout: Option, + /// Fill/Stroke proxy (Tools and Color panels): the stroke is in front, so Apply None and the + /// Color panel act on it (X toggles). + #[serde(skip)] + pub stroke_front: bool, } impl Default for UiState { @@ -370,6 +374,7 @@ impl Default for UiState { dialog: None, palette: None, flyout: None, + stroke_front: false, } } } @@ -499,6 +504,10 @@ pub struct DesignApp { pub color_applied: bool, pub perf: Perf, pub synthetic: Vec, + /// Control input stays separate from native-menu input until its frame is processed. + pub(crate) control_synthetic: Vec, + control_input_frame: std::sync::Arc, + clipboard_guard_installed: bool, /// Story open in the Story Editor. pub story_editor: Option, control_rx: Option>, @@ -527,6 +536,18 @@ pub struct DesignApp { /// (no native menu: without it the paste keys produce no paste event), or what it held then /// (native menu). It is never pasted as text: Paste with it pastes the copied objects. object_clip_text: Option, + /// Quit was chosen: close the window on the next frame, as its close button does. + quit_requested: bool, + /// Close the window without asking about unsaved changes (the control channel's `app.quit`). + pub(crate) quit_without_asking: bool, + /// No document is discarded until every unsaved document has an up-to-date approval. + quitting: Option, +} + +struct QuitState { + original_active: Option, + accepted: HashMap, + pending: Option, } impl DesignApp { @@ -550,6 +571,9 @@ impl DesignApp { color_applied: false, perf: Perf::default(), synthetic: vec![], + control_synthetic: vec![], + control_input_frame: Default::default(), + clipboard_guard_installed: false, story_editor: None, control_rx: None, pending_shots: vec![], @@ -567,6 +591,9 @@ impl DesignApp { egui_ctx: None, in_control: false, object_clip_text: None, + quit_requested: false, + quit_without_asking: false, + quitting: None, } } @@ -611,7 +638,7 @@ impl DesignApp { return r; } // Only user-initiated runs touch the system clipboard, never the control channel's. - let system_clipboard = !self.in_control; + let system_clipboard = !self.in_control && !self.control_input_frame.load(std::sync::atomic::Ordering::Relaxed); let params = if system_clipboard { self.with_system_clipboard(id, params) } else { params }; let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); self.after_engine(); @@ -898,7 +925,7 @@ impl DesignApp { if self.fonts_ready { self.drain_inbox(); } - if !self.synthetic.is_empty() { + if !self.synthetic.is_empty() || !self.control_synthetic.is_empty() { ctx.request_repaint(); } self.collect_screenshots(ctx); @@ -913,6 +940,151 @@ impl DesignApp { let _ = self.open_dropped(&p); } } + self.handle_quit(ctx); + } + + /// Quit: close the window the way its close button does, so unsaved documents ask first. + pub fn request_quit(&mut self) { + self.quit_requested = true; + } + + /// Ask about unsaved documents without closing any of them yet. A later Cancel must also + /// keep documents whose earlier answer was Don't Save, including their history and recovery. + fn handle_quit(&mut self, ctx: &egui::Context) { + if std::mem::take(&mut self.quit_requested) { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + if ctx.input(|i| i.viewport().close_requested()) { + if self.quit_without_asking { + self.quitting = None; + return; + } + if self.quitting.is_none() && !self.session.documents().iter().any(|d| d.is_dirty()) { + return; + } + let approved = + self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none(); + if approved { + // Commit only when the backend receives the final close, after checking for + // edits that arrived since the last answer. Closing also discards recovery data. + if let Err(e) = self.finish_confirmed_quit() { + self.status(e); + self.cancel_quit(); + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + // Dock Quit can arrive while the app is hidden or minimized. Make its question + // visible through the normal safe window commands before asking about documents. + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + if self.quitting.is_none() { + self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None }); + } + } + let Some(q) = self.quitting.as_ref() else { return }; + if let Some(uid) = q.pending { + let asking = self.ui.dialog.as_ref().is_some_and(|d| { + d.id == "closeDocument" + && d.fields.get("quitting").and_then(Value::as_bool) == Some(true) + && d.fields.get("uid").and_then(Value::as_u64) == Some(uid) + }); + if !asking { + self.cancel_quit(); + } + return; + } + if self.ui.dialog.is_some() { + self.cancel_quit(); + return; + } + self.ask_next_unsaved(ctx); + } + + /// Finalize a complete, still-current quit plan at an orderly host exit. The host can + /// terminate before egui receives its queued Close; an unfinished/stale plan leaves every + /// document live so the host can preserve its recovery data instead. + pub fn finish_confirmed_quit(&mut self) -> Result<(), String> { + let approved = + self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none(); + if !approved { + return Ok(()); + } + while !self.session.documents().is_empty() { + self.run("file.close", json!({"index": 0}))?; + } + self.quitting = None; + Ok(()) + } + + fn next_unapproved_document(&self) -> Option<(usize, u64)> { + let q = self.quitting.as_ref()?; + self.session + .documents() + .iter() + .enumerate() + .find(|(_, d)| d.is_dirty() && q.accepted.get(&d.uid) != Some(&d.revision)) + .map(|(i, d)| (i, d.uid)) + } + + fn cancel_quit(&mut self) { + let Some(q) = self.quitting.take() else { return }; + if let Some(i) = q.original_active.and_then(|uid| self.session.documents().iter().position(|d| d.uid == uid)) { + let _ = self.run("file.activate", json!({"index": i})); + } + } + + /// Save/Don't Save during Quit approves this UID's current revision, without destroying it. + pub(crate) fn confirm_quit_document(&mut self, uid: u64, discard: bool) -> Result { + if self.quitting.as_ref().and_then(|q| q.pending) != Some(uid) { + return Err("Quit confirmation is no longer active".into()); + } + let Some(i) = self.session.documents().iter().position(|d| d.uid == uid) else { + if let Some(q) = self.quitting.as_mut() { + q.pending = None; + } + return Ok(Value::Null); + }; + if !discard { + let saved = self.run("file.activate", json!({"index": i})).and_then(|_| self.run("app.save", json!({}))); + if let Err(e) = saved { + self.cancel_quit(); + return Err(e); + } + if self.session.documents().iter().find(|d| d.uid == uid).is_none_or(|d| d.is_dirty()) { + // No file chosen (or an asynchronous host save has not completed): stop quitting. + self.cancel_quit(); + return Ok(Value::Null); + } + } + if let Some(q) = self.quitting.as_mut() { + if let Some(d) = self.session.documents().iter().find(|d| d.uid == uid) { + q.accepted.insert(uid, d.revision); + } + q.pending = None; + } + Ok(Value::Null) + } + + /// Show the next unapproved document, or request a final close that revalidates all approvals. + fn ask_next_unsaved(&mut self, ctx: &egui::Context) { + let Some((i, uid)) = self.next_unapproved_document() else { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + }; + if self.run("file.activate", json!({"index": i})).is_err() { + self.cancel_quit(); + return; + } + menus::close_document(self, Some(i)); + if let Some(d) = self.ui.dialog.as_mut() { + d.fields.insert("quitting".into(), json!(true)); + } + if let Some(q) = self.quitting.as_mut() { + q.pending = Some(uid); + } } /// A file dropped on the window: documents open, anything else is placed. @@ -951,19 +1123,39 @@ impl DesignApp { r } - /// Inject synthetic events (one pointer event per frame). - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { - if self.synthetic.is_empty() { + /// Keep widget clipboard output private while a queued control event is running. + pub(crate) fn install_clipboard_guard(&mut self, ctx: &egui::Context) { + if self.clipboard_guard_installed { return; } - let n = match self.synthetic[0] { + let private = self.control_input_frame.clone(); + ctx.on_end_pass( + "control clipboard", + std::sync::Arc::new(move |ui| { + if private.load(std::sync::atomic::Ordering::Relaxed) { + ui.ctx().output_mut(|o| { + o.commands.retain(|cmd| !matches!(cmd, egui::OutputCommand::CopyText(_) | egui::OutputCommand::CopyImage(_))) + }); + } + }), + ); + self.clipboard_guard_installed = true; + } + + /// Inject synthetic events (one pointer event per frame), retaining control provenance. + pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + let private = !self.control_synthetic.is_empty(); + self.control_input_frame.store(private, std::sync::atomic::Ordering::Relaxed); + let events = if private { &mut self.control_synthetic } else { &mut self.synthetic }; + let Some(first) = events.first() else { return }; + let n = match first { egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + _ => events.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(events.len(), |i| i + 1), }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { raw.events.push(egui::Event::PointerMoved(*p)); } - raw.events.extend(self.synthetic.drain(..n)); + raw.events.extend(events.drain(..n)); } /// Lay out the whole window. @@ -1284,6 +1476,425 @@ mod tests { assert!(app.ui.dialog.is_none()); } + /// One frame of the quit handling, with the window asked to close when `close`; returns the + /// commands sent to the window. + fn quit_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) -> Vec { + let mut raw = egui::RawInput::default(); + if close { + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + } + let mut out = ctx.run_ui(raw, |ui| app.handle_quit(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default() + } + + fn dialog_uid(app: &DesignApp) -> Option { + app.ui.dialog.as_ref().filter(|d| d.id == "closeDocument").and_then(|d| d.fields.get("uid")?.as_u64()) + } + + /// Quitting closed the window at once, losing unsaved changes (#544). + #[test] + fn quitting_with_unsaved_changes_asks_and_cancel_keeps_everything() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let cmds = quit_frame(&mut app, &ctx, true); + assert!(cmds.contains(&egui::ViewportCommand::CancelClose), "{cmds:?}"); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid)); + // Cancel: the window and the document stay as they were. + app.ui.dialog = None; + for _ in 0..2 { + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + } + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.documents()[0].is_dirty()); + // A Save whose file picker is cancelled stops quitting too. + quit_frame(&mut app, &ctx, true); + dialogs::confirm(&mut app).unwrap(); + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(app.session.documents()[0].is_dirty()); + } + + #[test] + fn quitting_asks_about_each_unsaved_document_then_closes() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 136, 136]})).unwrap(); + app.run("file.new", json!({})).unwrap(); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + for (n, uid) in uids.iter().take(2).enumerate() { + assert_eq!(dialog_uid(&app), Some(*uid), "question {n}"); + assert_eq!(app.session.active().map(|d| d.uid), Some(*uid), "the document asked about is shown"); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + let cmds = quit_frame(&mut app, &ctx, false); + assert_eq!(cmds.contains(&egui::ViewportCommand::Close), n == 1, "{cmds:?}"); + } + // All documents stay open until the backend's final close commits the decision. + assert_eq!(app.session.documents().len(), 3); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.session.documents().is_empty()); + } + + /// A later Cancel must not destroy documents already approved for discard. + #[test] + fn quitting_later_cancel_retains_every_document_and_active_tab() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + let active = app.session.active().unwrap().uid; + let revisions: Vec = app.session.documents().iter().map(|d| d.revision).collect(); + let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + quit_frame(&mut app, &ctx, true); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::>(), uids); + assert_eq!(app.session.active().unwrap().uid, active); + assert_eq!(app.session.documents().iter().map(|d| d.revision).collect::>(), revisions); + for (d, original) in app.session.documents().iter().zip(docs) { + assert!(std::sync::Arc::ptr_eq(&d.doc, &original)); + assert!(d.is_dirty()); + assert!(!d.history.undo.is_empty()); + } + } + + fn second_unsaved_document(app: &mut DesignApp) { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + + fn discard_quit_dialog(app: &mut DesignApp) { + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(app).unwrap(); + } + + #[test] + fn native_quit_prompts_restore_window_visibility_without_focusing_completed_quits() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let commands = quit_frame(&mut app, &ctx, true); + assert!(commands.contains(&egui::ViewportCommand::CancelClose)); + for command in [egui::ViewportCommand::Visible(true), egui::ViewportCommand::Minimized(false), egui::ViewportCommand::Focus] { + assert!(commands.contains(&command), "unsaved prompt must restore the window: {commands:?}"); + } + discard_quit_dialog(&mut app); + let approved = quit_frame(&mut app, &ctx, false); + assert!(approved.contains(&egui::ViewportCommand::Close)); + let completed = quit_frame(&mut app, &ctx, true); + assert!(app.session.documents().is_empty()); + + let mut clean = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + clean.run("file.new", json!({})).unwrap(); + let clean_close = quit_frame(&mut clean, &ctx, true); + let mut control = app_with_unsaved_document(); + control.quit_without_asking = true; + let control_close = quit_frame(&mut control, &ctx, true); + for commands in [&approved, &completed, &clean_close, &control_close] { + assert!( + !commands.iter().any(|command| matches!( + command, + egui::ViewportCommand::Visible(true) | egui::ViewportCommand::Minimized(false) | egui::ViewportCommand::Focus + )), + "completed and immediate quits must not activate the window: {commands:?}" + ); + } + } + + /// Dock Quit may arrive again while the first request's dialogs are still open. + #[test] + fn repeated_native_quit_requests_preserve_partial_approvals_and_cancel() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let original_active = app.session.active().unwrap().uid; + let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect(); + let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let directory = tempfile::tempdir().unwrap(); + app.session.recovery_dir = Some(directory.path().to_path_buf()); + app.run("file.recovery.save", json!({})).unwrap(); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[0])); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + for _ in 0..3 { + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[1])); + } + app.ui.dialog = None; // Cancel the second document, retaining the first Don't Save answer. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.active().unwrap().uid, original_active); + assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::>(), uids); + for (document, original) in app.session.documents().iter().zip(docs) { + assert!(std::sync::Arc::ptr_eq(&document.doc, &original)); + assert!(document.is_dirty()); + assert!(!document.history.undo.is_empty()); + assert!(directory.path().join(format!("{}.designcraft", document.uid)).exists()); + } + // A fresh Dock Quit must ask about the first document again after cancellation. + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[0])); + } + + #[test] + fn repeated_native_quit_requests_save_and_discard_only_after_all_answers() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("saved.designcraft"); + let chosen = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(chosen.clone()))); + app.session.recovery_dir = Some(directory.path().join("recovery")); + app.run("file.recovery.save", json!({})).unwrap(); + quit_frame(&mut app, &ctx, true); + assert_eq!(dialog_uid(&app), Some(uids[0])); + dialogs::confirm(&mut app).unwrap(); // Save the first document. + assert!(!app.session.documents()[0].is_dirty()); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[1])); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.recovery_dir.as_ref().unwrap().join(format!("{}.designcraft", uids[1])).exists()); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.session.documents().is_empty()); + assert!(path.exists()); + assert_eq!(designcraft_engine::Session::new().execute("file.open", &json!({"path": path})).unwrap()["index"], 0); + assert_eq!(std::fs::read_dir(app.session.recovery_dir.as_ref().unwrap()).unwrap().count(), 0); + } + + #[test] + fn quitting_first_cancel_restores_original_clean_active_document() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + let original_active = app.session.active().unwrap().uid; + quit_frame(&mut app, &ctx, true); + assert_ne!(app.session.active().unwrap().uid, original_active); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.active().unwrap().uid, original_active); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_save_as_cancel_after_discard_keeps_all_documents() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let active = app.session.active().unwrap().uid; + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + dialogs::confirm(&mut app).unwrap(); // No picker: Save As was cancelled. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.documents().iter().all(|d| d.is_dirty())); + assert_eq!(app.session.active().unwrap().uid, active); + } + + #[test] + fn quitting_save_failure_after_discard_keeps_documents_and_can_retry() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let path = std::env::temp_dir() + .join(format!("designcraft-quit-missing-{}-{}", std::process::id(), app.session.active().unwrap().uid)) + .join("missing") + .join("document.designcraft"); + assert!(!path.parent().unwrap().exists()); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + assert!(dialogs::confirm(&mut app).is_err()); + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.documents().iter().all(|d| d.is_dirty())); + // Starting over must ask about the previously discarded first document again. + quit_frame(&mut app, &ctx, true); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid)); + } + + #[test] + fn quitting_save_then_later_cancel_keeps_saved_document_open() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + let path = std::env::temp_dir().join(format!("designcraft-quit-save-{}-{first}.designcraft", std::process::id())); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + dialogs::confirm(&mut app).unwrap(); + quit_frame(&mut app, &ctx, false); + assert_eq!(app.session.documents().len(), 2); + assert!(!app.session.documents()[0].is_dirty()); + assert!(path.exists()); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents()[0].uid, first); + assert!(app.session.documents()[1].is_dirty()); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn quitting_rechecks_discard_approval_after_a_later_edit() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + app.run("file.activate", json!({"index": 0})).unwrap(); + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + discard_quit_dialog(&mut app); // Approval is bound to the second UID, despite the switch. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(dialog_uid(&app), Some(first), "new edit needs a fresh decision"); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_final_close_rechecks_new_unsaved_document() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + second_unsaved_document(&mut app); // Arrives after Close was queued, before its event. + let new_uid = app.session.active().unwrap().uid; + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(new_uid)); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_pending_save_uses_dialog_uid_after_tab_switch() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + let path = std::env::temp_dir().join(format!("designcraft-quit-uid-{}-{first}.designcraft", std::process::id())); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + app.run("file.activate", json!({"index": 1})).unwrap(); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.documents()[0].path.as_deref(), path.to_str()); + assert!(!app.session.documents()[0].is_dirty()); + assert!(app.session.documents()[1].is_dirty()); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[1].uid)); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn quitting_discard_preserves_recovery_until_final_commit() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let uid = app.session.active().unwrap().uid; + let dir = std::env::temp_dir().join(format!("designcraft-quit-recovery-{}-{uid}", std::process::id())); + app.session.recovery_dir = Some(dir.clone()); + app.run("file.recovery.save", json!({})).unwrap(); + let entry = dir.join(format!("{uid}.designcraft")); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(entry.exists(), "a queued close is not a committed close"); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(!entry.exists(), "accepted Don't Save must not be recovered on restart"); + assert!(app.session.documents().is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn quitting_native_exit_commits_completed_approvals_before_close_event() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 1); + app.finish_confirmed_quit().unwrap(); + assert!(app.session.documents().is_empty()); + } + + #[test] + fn quitting_native_exit_retains_incomplete_stale_and_new_document_plans() { + for case in ["incomplete", "stale", "new document"] { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + if case == "incomplete" { + second_unsaved_document(&mut app); + } + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + if case == "stale" { + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + } else if case == "new document" { + second_unsaved_document(&mut app); + } + let active = app.session.active().unwrap().uid; + let before: Vec<_> = app + .session + .documents() + .iter() + .map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len())) + .collect(); + app.finish_confirmed_quit().unwrap(); + assert_eq!(app.session.documents().len(), before.len(), "{case}"); + assert_eq!(app.session.active().unwrap().uid, active, "{case}"); + for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) { + assert_eq!( + (d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()), + (uid, revision, &selection, undo, redo), + "{case}" + ); + assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}"); + } + } + } + + #[test] + fn quitting_without_unsaved_changes_closes_at_once() { + let ctx = egui::Context::default(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + // Quit (⌘Q) closes the window like its close button. + app.run("app.quit", json!({})).unwrap(); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.ui.dialog.is_none()); + } + + /// Scripts must not hang on a question: the control channel's quit never asks. + #[test] + fn control_channel_quit_does_not_ask() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let (req, _rx) = control::ControlRequest::new("app.quit", json!({})); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + control::handle(&mut app, ui.ctx(), &req); + }); + out.textures_delta.clear(); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.ui.dialog.is_none()); + } + /// The Control panel's two-row groups were centred as if they were one row tall, so the second /// row hung below the panel and was cut off. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ee28b6447..dc8b88bf2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -11,6 +11,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.placeDialog", "Place…", Some("Cmd+D"), "{}"), ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"), ("app.save", "Save", Some("Cmd+S"), "{}"), + ("app.quit", "Quit DesignCraft", Some("Cmd+Q"), "{} — close the window; each document with unsaved changes asks first"), ("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{} — choose where to write a copy (the document stays as it is)"), ("app.loadSwatches", "Load Swatches…", None, "{} — pick a swatch exchange (.ase) file"), ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"), @@ -91,6 +92,20 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ Some("J"), "{on?: bool} — with text frames selected, colours go to their text (on) or the frames (off); toggles without `on`", ), + ( + "color.defaultFillStroke", + "Default Fill and Stroke", + Some("D"), + "{} — the selection gets no fill and a black stroke (object.defaultFillStroke)", + ), + ( + "color.swapFillStroke", + "Swap Fill and Stroke", + Some("Shift+X"), + "{} — each selected object's fill and stroke colours (and tints) trade places (object.swapFillStroke)", + ), + ("color.toggleFillStroke", "Toggle Fill and Stroke", Some("X"), "{} → {strokeFront} — which of fill and stroke the proxy has in front"), + ("color.applyNone", "Apply None", Some("/"), "{} — [None] for the fill or stroke in front of the proxy"), ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"), ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"), @@ -408,34 +423,6 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.sizeDown", "-", "ui:view.hiddenCharacters", - "ui:view.taggedFrames", - ">Rotate Spread", - "ui:view.rotateSpread|90° CW|{\"angle\": 90}", - "ui:view.rotateSpread|90° CCW|{\"angle\": -90}", - "ui:view.rotateSpread|180°|{\"angle\": 180}", - "ui:view.rotateSpread|Clear Rotation|{\"angle\": 0}", - "<", - ">Proof Setup", - "ui:view.proofSetup|Working CMYK|{\"target\": \"workingCmyk\"}", - "ui:view.proofSetup|Internet Standard RGB (sRGB)|{\"target\": \"srgb\"}", - "ui:view.proofSetup|Legacy Macintosh RGB|{\"target\": \"legacyMacRgb\"}", - "ui:view.proofSetup|Monitor RGB|{\"target\": \"monitorRgb\"}", - "ui:view.proofSetup|Color Blindness – Protanopia|{\"target\": \"protanopia\"}", - "ui:view.proofSetup|Color Blindness – Deuteranopia|{\"target\": \"deuteranopia\"}", - "-", - "ui:view.proofSetup|Simulate Paper Color|{\"simulatePaper\": \"toggle\"}", - "<", - "ui:view.proofColors", - "ui:view.flattenerPreview", - "ui:view.tagMarkers", - ">Separations Preview", - "ui:view.separations|Off|{\"plate\": null, \"inkLimit\": null}", - "ui:view.separations|Cyan|{\"plate\": \"cyan\"}", - "ui:view.separations|Magenta|{\"plate\": \"magenta\"}", - "ui:view.separations|Yellow|{\"plate\": \"yellow\"}", - "ui:view.separations|Black|{\"plate\": \"black\"}", - "ui:view.separations|Ink Limit 300%|{\"inkLimit\": 300}", - "<", "-", ">Hyperlinks & Cross-References", "cmd:hyperlink.create", @@ -491,6 +478,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ ">Paths", "cmd:object.makeCompoundPath", "cmd:object.releaseCompoundPath", + "-", + "cmd:path.reverse", "<", ">Pathfinder", "cmd:object.pathfinder|Add|{\"op\": \"add\"}", @@ -625,6 +614,28 @@ pub const MENUS: &[(&str, &[&str])] = &[ ( "View", &[ + "ui:view.overprintPreview", + ">Proof Setup", + "ui:view.proofSetup|Working CMYK|{\"target\": \"workingCmyk\"}", + "ui:view.proofSetup|Internet Standard RGB (sRGB)|{\"target\": \"srgb\"}", + "ui:view.proofSetup|Legacy Macintosh RGB|{\"target\": \"legacyMacRgb\"}", + "ui:view.proofSetup|Monitor RGB|{\"target\": \"monitorRgb\"}", + "ui:view.proofSetup|Color Blindness – Protanopia|{\"target\": \"protanopia\"}", + "ui:view.proofSetup|Color Blindness – Deuteranopia|{\"target\": \"deuteranopia\"}", + "-", + "ui:view.proofSetup|Simulate Paper Color|{\"simulatePaper\": \"toggle\"}", + "<", + "ui:view.proofColors", + ">Separations Preview", + "ui:view.separations|Off|{\"plate\": null, \"inkLimit\": null}", + "ui:view.separations|Cyan|{\"plate\": \"cyan\"}", + "ui:view.separations|Magenta|{\"plate\": \"magenta\"}", + "ui:view.separations|Yellow|{\"plate\": \"yellow\"}", + "ui:view.separations|Black|{\"plate\": \"black\"}", + "ui:view.separations|Ink Limit 300%|{\"inkLimit\": 300}", + "<", + "ui:view.flattenerPreview", + "-", "ui:view.zoomIn", "ui:view.zoomOut", "ui:view.fitPage", @@ -633,8 +644,20 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.actualSize", "ui:view.entirePasteboard", "-", - "ui:view.overprintPreview", - "ui:view.togglePreview", + ">Rotate Spread", + "ui:view.rotateSpread|90° CW|{\"angle\": 90}", + "ui:view.rotateSpread|90° CCW|{\"angle\": -90}", + "ui:view.rotateSpread|180°|{\"angle\": 180}", + "ui:view.rotateSpread|Clear Rotation|{\"angle\": 0}", + "<", + "-", + ">Screen Mode", + "ui:view.screenMode|Normal|{\"mode\": \"normal\"}", + "ui:view.screenMode|Preview|{\"mode\": \"preview\"}", + "ui:view.screenMode|Bleed|{\"mode\": \"bleed\"}", + "ui:view.screenMode|Slug|{\"mode\": \"slug\"}", + "ui:view.screenMode|Presentation|{\"mode\": \"presentation\"}", + "<", ">Display Performance", "ui:view.fastDisplay", "ui:view.typicalDisplay", @@ -642,10 +665,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "-", "ui:view.rulers", + "-", + ">Extras", "ui:view.frameEdges", "ui:view.textThreads", - "ui:view.hiddenCharacters", - "-", + "<", ">Grids & Guides", "ui:view.guides", "ui:view.snapToGuides", @@ -655,6 +679,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "ui:app.deleteAllGuides", "<", + "-", + ">Structure", + "ui:view.taggedFrames", + "ui:view.tagMarkers", + "<", ], ), ( @@ -775,6 +804,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + app.request_quit(); + Ok(Value::Null) + } "app.saveCopyDialog" => { let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default(); if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) { @@ -1115,6 +1148,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option crate::toolbar::default_fill_stroke(app), + "color.swapFillStroke" => crate::toolbar::swap_fill_stroke(app), + "color.toggleFillStroke" => crate::toolbar::toggle_fill_stroke(app), + "color.applyNone" => crate::toolbar::apply_none(app), "view.frameEdges" => flag(&mut app.ui.frame_edges), "view.overprintPreview" => { app.canvas.shown = None; @@ -1786,6 +1823,17 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.proofColors" => app.ui.proof_colors, "view.flattenerPreview" => app.ui.flattener_preview, "view.tagMarkers" => app.ui.tag_markers, + "view.screenMode" => { + let mode = match params.get("mode").and_then(Value::as_str) { + Some("normal") => crate::ScreenMode::Normal, + Some("preview") => crate::ScreenMode::Preview, + Some("bleed") => crate::ScreenMode::Bleed, + Some("slug") => crate::ScreenMode::Slug, + Some("presentation") => crate::ScreenMode::Presentation, + _ => return None, + }; + app.ui.screen_mode == mode + } "view.rotateSpread" => match params.get("angle").and_then(Value::as_i64) { Some(0) => app.session.active().is_some_and(|st| st.doc.spreads.iter().all(|sp| sp.pages.first().is_none_or(|p| p.view_rotation == 0))), _ => return None, @@ -2107,6 +2155,7 @@ fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { "," => key = Some(egui::Key::Comma), "." => key = Some(egui::Key::Period), "\\" => key = Some(egui::Key::Backslash), + "/" => key = Some(egui::Key::Slash), "Delete" => key = Some(egui::Key::Delete), k => key = egui::Key::from_name(k), } @@ -2284,6 +2333,64 @@ pub fn quick_apply_items(session: &designcraft_engine::Session, query: &str) -> mod tests { use super::*; + /// The entries (command ids) of a menu bar menu, submenus included. + fn menu_ids(title: &str) -> Vec<&'static str> { + let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); + items.iter().filter_map(|e| e.split_once(':').map(|(_, rest)| rest.split('|').next().unwrap_or(rest))).collect() + } + + #[test] + fn view_items_are_in_the_view_menu_as_in_indesign() { + let (view, typ) = (menu_ids("View"), menu_ids("Type")); + for id in [ + "view.rotateSpread", + "view.proofSetup", + "view.proofColors", + "view.flattenerPreview", + "view.separations", + "view.taggedFrames", + "view.tagMarkers", + ] { + assert!(view.contains(&id), "{id} is in View"); + assert!(!typ.contains(&id), "{id} isn't in Type"); + } + // Screen Mode and Extras are View submenus; Show Hidden Characters stays in Type. + assert!(view.contains(&"view.screenMode") && view.contains(&"view.frameEdges")); + assert!(typ.contains(&"view.hiddenCharacters") && !view.contains(&"view.hiddenCharacters")); + // W still toggles Normal / Preview without a menu entry. + assert_eq!(ui_label("view.togglePreview").and_then(|c| c.1), Some("W")); + } + + /// The submenu paths of a menu bar menu ("View/Screen Mode"), as Edit › Menus keys them. + fn submenu_paths(title: &str) -> Vec { + let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); + let mut stack = vec![title.to_string()]; + let mut out = Vec::new(); + for e in items { + if let Some(name) = e.strip_prefix('>') { + let path = format!("{}/{name}", stack.last().map_or("", String::as_str)); + out.push(path.clone()); + stack.push(path); + } else if *e == "<" { + stack.pop(); + } + } + out + } + + #[test] + fn screen_mode_shows_the_current_mode_and_view_submenus_are_translated() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mode = |app: &DesignApp, m: &str| checked(app, "view.screenMode", &json!({ "mode": m })); + assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(true), Some(false))); + app.ui.screen_mode = crate::ScreenMode::Preview; + assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true))); + // Every View submenu has its Japanese path (Edit › Menus shows them). + for path in submenu_paths("View") { + assert_ne!(crate::i18n::tr("ja", &path), path, "{path} has no Japanese row"); + } + } + fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) { for it in items { match it { @@ -2389,6 +2496,10 @@ mod tests { fn formatting_affects_text_sends_swatches_to_the_text() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); + for (name, color) in [("Cyan", "#00ffff"), ("Magenta", "#ff00ff")] { + let created = app.run("swatch.create", json!({"name": name, "color": color})).unwrap(); + assert_eq!(created["name"], name); + } let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "words", "caret": false})).unwrap(); let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); app.run("selection.set", json!({"ids": [fid]})).unwrap(); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index e53986c5c..05fc1150c 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -418,31 +418,7 @@ fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { let Some(panel) = normalize(panel) else { return }; - let language = app.ui.language.clone(); - response.context_menu(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() { - if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { - app.status(error); - } - ui.close(); - } - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| { - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { - if *anchor != panel && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, label))).clicked() { - // A closed icon panel is made visible before becoming the destination. - let result = app - .run("window.panel.activate", json!({"panel": anchor})) - .and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor}))); - if let Err(error) = result { - app.status(error); - } - ui.close(); - } - } - }); - }); - }); + response.context_menu(|ui| panel_menu(app, ui, panel)); if response.drag_started() && let Some(id) = normalize(panel) { @@ -472,6 +448,34 @@ pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, re } } +/// The docking commands of a panel: its tab's context menu and the end of its panel menu. +pub(crate) fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { + let Some(panel) = normalize(panel) else { return }; + let language = app.ui.language.clone(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() { + if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { + app.status(error); + } + ui.close(); + } + ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + if *anchor != panel && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, label))).clicked() { + // A closed icon panel is made visible before becoming the destination. + let result = app + .run("window.panel.activate", json!({"panel": anchor})) + .and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor}))); + if let Err(error) = result { + app.status(error); + } + ui.close(); + } + } + }); + }); +} + struct PanelContent<'a>(&'a mut DesignApp); impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 210ae0ce0..de08e3cf5 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -597,3 +597,28 @@ fn capture_legacy_panel_menu_visual_fixture() { assert_eq!(serde_json::to_value(&h.state().session.active().unwrap().doc).unwrap(), doc); h.render().unwrap().save(directory.join("designcraft-legacy-last-target-selected-dark-ar.png")).unwrap(); } + +#[test] +fn legacy_dock_menu_button_opens_the_active_panel_menu() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.ui.dock_tab = "pages".into(); + let pages = app.session.active().unwrap().doc.page_count(); + let mut h = harness(app, vec2(1280.0, 800.0)); + assert!(h.state().ui.docking.is_none()); + let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); + let dock = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("dock")).unwrap().outer_rect; + let at = egui::pos2(dock.right() - 15.0, tab.center().y); + h.event(Event::PointerMoved(at)); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); + h.get_by_label("Insert Page After").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); + assert!(!egui::Popup::is_any_open(&h.ctx)); +} diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6bbad9066..ae323225b 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -216,6 +216,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// The panel menu's page commands, acting on the current page and its spread. +pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) { + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let cur = crate::canvas::current_page(app).unwrap_or(0); + let Some((si, _)) = doc.page_loc(cur) else { return }; + page_menu(app, ui, &doc, cur, si); +} + fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Insert Page After"))).clicked() { let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1})); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 43b68b096..4e207c44f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -121,6 +121,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { section(ui, crate::i18n::tr(&app.ui.language, "Align"), true); align_row(app, ui); divider(ui); + if i.count > 1 { + section(ui, crate::i18n::tr(&app.ui.language, "Pathfinder"), true); + pathfinder_row(app, ui); + divider(ui); + } if i.is_text && i.count == 1 { section(ui, crate::i18n::tr(&app.ui.language, "Text Frame"), true); text_frame_section(app, ui); @@ -460,11 +465,62 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { set.insert("ref".into(), json!(rp)); let _ = app.run("transform.set", Value::Object(set)); } + transform_angles(app, ui, rp); if more_options(ui, &app.ui.language).clicked() { app.ui.open_panel = Some("info".into()); } } +/// Properties › Transform, below X/Y/W/H as in InDesign: rotation and shear angles beside the +/// X and Y scales (totals, as Transformations are Totals), then rotate a quarter turn and flip. +fn transform_angles(app: &mut DesignApp, ui: &mut Ui, rp: u8) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tv = app.session.execute("transform.info", &json!({})).ok(); + let tv_get = |k: &str| tv.as_ref().and_then(|v| v[k].as_f64()); + ui.add_space(4.0); + let b = block(ui, 2.0 * FIELD_H + 4.0); + let mut set = serde_json::Map::new(); + let rows = [ + [("tool-rotate", "Rotation Angle", "rotation", "°", "p_rot"), ("tool-scale", "Scale X Percentage", "scaleX", "%", "p_sx")], + [("tool-shear", "Shear X Angle", "shear", "°", "p_shear"), ("tool-scale", "Scale Y Percentage", "scaleY", "%", "p_sy")], + ]; + for (row, cells) in rows.into_iter().enumerate() { + let yy = row as f32 * 25.0; + for (col, (icon, tip, key, suffix, id)) in cells.into_iter().enumerate() { + let x = if col == 0 { 40.0 } else { 120.0 }; + let ir = sub(b, x, yy + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.id().with(id), Sense::hover()).on_hover_text(crate::i18n::tr(&lang, tip)); + if let Some(v) = place(ui, sub(b, x + 19.0, yy, 54.0, FIELD_H), |ui| widgets::number(ui, id, tv_get(key), suffix, 54.0, 1)) { + set.insert(key.into(), json!(v)); + } + } + } + if !set.is_empty() { + set.insert("ref".into(), json!(rp)); + let _ = app.run("transform.set", Value::Object(set)); + } + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.add_space(36.0); + ui.spacing_mut().item_spacing.x = 10.0; + for (icon, tip, cmd, params) in [ + ("rotate-cw", "Rotate 90° Clockwise", "transform.rotate", json!({"angle": -90})), + ("rotate-ccw", "Rotate 90° Counterclockwise", "transform.rotate", json!({"angle": 90})), + ("flip-h", "Flip Horizontal", "transform.flip", json!({"axis": "horizontal"})), + ("flip-v", "Flip Vertical", "transform.flip", json!({"axis": "vertical"})), + ] { + let label = crate::i18n::tr(&lang, tip); + let resp = icon_toggle(ui, icon, false, label); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + if resp.clicked() { + let _ = app.run(cmd, params); + } + } + }); +} + /// A stroke-type preview dropdown (light box with the line style). fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, w: f32) { const TYPES: &[(&str, &str)] = &[ @@ -1763,6 +1819,27 @@ pub fn wrap_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The Pathfinder operations, in the order of InDesign's Pathfinder buttons. +const PATHFINDER: [(&str, &str, &str); 5] = [ + ("pf-add", "add", "Add"), + ("pf-subtract", "subtract", "Subtract"), + ("pf-intersect", "intersect", "Intersect"), + ("pf-exclude", "exclude", "Exclude Overlap"), + ("pf-minus-back", "minusBack", "Minus Back"), +]; + +/// Properties › Pathfinder (several objects selected): combine them into one shape. +fn pathfinder_row(app: &mut DesignApp, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + for (icon, op, tip) in PATHFINDER { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("object.pathfinder", json!({"op": op})); + } + } + }); +} + /// Pathfinder panel: combine the selected shapes; make or release compound paths. pub fn pathfinder_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Pathfinder")).strong()); @@ -2027,6 +2104,13 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("object.opacity", json!({"opacity": (v / 100.0).clamp(0.0, 1.0)})); } }); + // The level the settings apply to, with its blend mode and opacity ("Group: Color Dodge 100%"). + let group = it.is_group(); + ui.horizontal(|ui| { + let level = crate::i18n::tr(&app.ui.language, if group { "Group:" } else { "Object:" }); + crate::rtl::label(ui, egui::RichText::new(level).font(crate::theme::semibold(12.0))); + crate::rtl::label(ui, format!("{} {}%", crate::i18n::tr(&app.ui.language, it.blend.label()), (it.opacity * 100.0).round())); + }); divider(ui); let ds = it.effects.drop_shadow.clone(); let mut on = ds.on; @@ -2069,6 +2153,20 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.end_row(); }); } + divider(ui); + // Groups only, as in InDesign (greyed for other objects). + ui.horizontal(|ui| { + let mut iso = it.isolate; + let label = crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Isolate Blending")); + if ui.add_enabled(group, egui::Checkbox::new(&mut iso, label)).changed() { + let _ = app.run("object.transparencyGroup", json!({"isolate": iso})); + } + let mut ko = it.knockout; + let label = crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Knockout Group")); + if ui.add_enabled(group, egui::Checkbox::new(&mut ko, label)).changed() { + let _ = app.run("object.transparencyGroup", json!({"knockout": ko})); + } + }); } pub fn preflight_panel(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -2246,4 +2344,178 @@ mod tests { let after = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone(); assert_eq!(after, name, "clicking the swatch chip applies it"); } + + #[test] + fn transform_flips_and_rotates_from_properties() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let xf = |h: &Harness<'_, DesignApp>| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().xf; + // Flip Horizontal mirrors the frame (a negative determinant); Flip Vertical as well brings it + // back to a half turn, as in the tutorial's petal. + h.get_by_label("Flip Horizontal").click(); + h.run_steps(3); + assert!(xf(&h).determinant() < 0.0, "{:?}", xf(&h)); + h.get_by_label("Flip Vertical").click(); + h.run_steps(3); + let c = xf(&h).as_coeffs(); + assert!((c[0] + 1.0).abs() < 1e-9 && (c[3] + 1.0).abs() < 1e-9, "a half turn: {c:?}"); + let info = h.state_mut().session.execute("transform.info", &json!({})).unwrap(); + assert!((info["rotation"].as_f64().unwrap().abs() - 180.0).abs() < 1e-6, "{info}"); + // A quarter turn clockwise from there. + h.get_by_label("Rotate 90° Clockwise").click(); + h.run_steps(3); + let info = h.state_mut().session.execute("transform.info", &json!({})).unwrap(); + assert!((info["rotation"].as_f64().unwrap() - 90.0).abs() < 1e-6, "{info}"); + // The angle and scale fields are there. + assert!(h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("90°")).is_some()); + } + + fn panel(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + #[test] + fn several_shapes_show_pathfinder_and_intersect_combines_them() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [100, 100, 300, 300], "shape": "ellipse"})).unwrap()["id"].clone(); + let b = app.run("frame.create", json!({"rect": [200, 100, 300, 200]})).unwrap()["id"].clone(); + // One shape: no Pathfinder section. + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let mut h = panel(app); + assert!(h.query_by_label("Pathfinder").is_none()); + // Two: the section and its five buttons; Intersect leaves one shape, the ellipse's quarter. + h.state_mut().run("selection.set", json!({"ids": [a, b]})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("Pathfinder").is_some()); + for op in ["Add", "Subtract", "Intersect", "Exclude Overlap", "Minus Back"] { + assert!(h.query_by_label(op).is_some(), "{op}"); + } + h.get_by_label("Intersect").click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + let items = &doc.spreads[0].items; + assert_eq!(items.len(), 1, "one shape left"); + let r = items[0].bounds(); + assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); + } +} + +#[cfg(test)] +mod effects_panel_tests { + use super::*; + + /// One frame of the Effects panel: the text painted and where. + fn frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 400.0)); + let time = ctx.input(|i| i.time) + 1.0 / 60.0; + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| effects_panel(app, ui)); + out.textures_delta.clear(); + let mut v = Vec::new(); + for s in out.shapes { + if let egui::Shape::Text(t) = s.shape { + v.push((t.galley.job.text.clone(), t.visual_bounding_rect())); + } + } + v + } + + fn click(app: &mut DesignApp, ctx: &egui::Context, label: &str) { + let labels = frame(app, ctx, vec![]); + let pos = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} is painted")).1.center(); + for pressed in [true, false] { + frame( + app, + ctx, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + } + + #[test] + fn the_effects_panel_shows_the_level_and_isolate_blending_and_knockout_group_for_groups() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 144, 144]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let g = app.run("object.group", json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [g]})).unwrap(); + app.run("object.opacity", json!({"opacity": 1.0, "blend": "colorDodge"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + crate::theme::apply(&ctx, &Tokens::for_brightness(crate::theme::Brightness::default())); + let texts: Vec = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + for expected in ["Group:", "Color Dodge 100%", "Isolate Blending", "Knockout Group"] { + assert!(texts.iter().any(|t| t == expected), "{expected} in {texts:?}"); + } + let item = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).cloned().unwrap(); + click(&mut app, &ctx, "Isolate Blending"); + assert!(item(&app, g).isolate); + click(&mut app, &ctx, "Knockout Group"); + assert!(item(&app, g).knockout); + // Another object: the level is Object, and the group options are greyed. + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let texts: Vec = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + assert!(texts.iter().any(|t| t == "Object:"), "{texts:?}"); + click(&mut app, &ctx, "Knockout Group"); + assert!(!item(&app, a).knockout); + } + + #[test] + fn pasted_into_frames_keep_object_level_and_disable_group_blending() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let child = app.run("frame.create", json!({"rect": [80, 80, 120, 120]})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [child]})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + let container = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [container]})).unwrap(); + app.run("edit.pasteInto", json!({})).unwrap(); + app.run("selection.set", json!({"ids": [container]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + crate::theme::apply(&ctx, &Tokens::for_brightness(crate::theme::Brightness::default())); + let texts: Vec = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + assert!(texts.iter().any(|t| t == "Object:"), "a frame remains an object after Paste Into: {texts:?}"); + let undo = app.session.doc().unwrap().history.undo.len(); + click(&mut app, &ctx, "Isolate Blending"); + click(&mut app, &ctx, "Knockout Group"); + let st = app.session.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(container)).unwrap(); + assert!(it.has_nested_items()); + assert!(!it.isolate && !it.knockout, "group-only options stay disabled for a frame holding content"); + assert_eq!(st.history.undo.len(), undo, "disabled controls do not create undo steps"); + } } diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 25a596644..d64cc1bd5 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -150,3 +150,44 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren } }); } + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + #[test] + fn character_style_plus_captures_selection_for_reuse() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "alpha beta"})).unwrap(); + app.run("text.select", json!({"story": frame["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("type.char", json!({"underline": true})).unwrap(); + let selection = app.session.doc().unwrap().selection.clone(); + let undo_len = app.session.doc().unwrap().history.undo.len(); + let mut h = Harness::builder().with_size(vec2(360.0, 240.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + character(app, ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Create New Style from Selection").click(); + h.run_steps(3); + let app = h.state_mut(); + let style = app.session.doc().unwrap().doc.styles.char_style("Character Style 1").unwrap(); + assert_eq!(style.chars, designcraft_doc::CharAttrs { underline: Some(true), ..Default::default() }); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len + 1); + app.run("text.select", json!({"story": frame["story"], "anchor": 6, "focus": 10})).unwrap(); + app.run("style.character.apply", json!({"name": "Character Style 1"})).unwrap(); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["underline"], true); + } +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 5bbf5fd29..967ff38f4 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -208,9 +208,8 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }; let text = super::colors_affect_text(app); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; - let stroke_id = egui::Id::new("color_panel_stroke"); let mode_id = egui::Id::new("color_panel_mode"); - let mut stroke: bool = ui.data(|d| d.get_temp(stroke_id)).unwrap_or(false); + let mut stroke = app.ui.stroke_front; let default_mode = if doc.settings.intent == designcraft_doc::Intent::Print { ColorMode::Cmyk } else { ColorMode::Rgb }; let mut mode: ColorMode = ui.data(|d| d.get_temp(mode_id)).unwrap_or(default_mode); let target = if stroke { "stroke" } else { "fill" }; @@ -253,10 +252,8 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); }); - ui.data_mut(|d| { - d.insert_temp(stroke_id, stroke); - d.insert_temp(mode_id, mode); - }); + app.ui.stroke_front = stroke; + ui.data_mut(|d| d.insert_temp(mode_id, mode)); let sw = doc.swatch(&name).cloned(); let Some(c) = doc.resolve_color(&name, 1.0) else { ui.label(crate::rtl::widget( diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index 2521aae20..a554bc64a 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -76,6 +76,14 @@ pub fn plain(ctx: &egui::Context, text: &str, font: FontId, color: Color32) -> A layout(ctx, LayoutJob::simple_singleline(text.to_owned(), font, color)) } +/// One line no wider than `max_width`: longer text ends in an ellipsis, cut at a grapheme +/// boundary in logical order (`Galley::elided` tells whether it was cut). +pub fn truncated(ctx: &egui::Context, text: &str, font: FontId, color: Color32, max_width: f32) -> Arc { + let mut job = LayoutJob::simple_singleline(text.to_owned(), font, color); + job.wrap = TextWrapping::truncate_at_width(max_width.max(0.0)); + layout(ctx, job) +} + pub fn paint(p: &egui::Painter, pos: Pos2, anchor: Align2, text: &str, font: FontId, color: Color32) { let galley = plain(p.ctx(), text, font, color); let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size())); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 03b297dd4..6e7d98487 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -196,8 +196,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let t = Tokens::get(ui.ctx()); let target = crate::panels::color_target(app); let doc = app.session.active().map(|d| d.doc.clone()); - let stroke_front_id = egui::Id::new("proxy_stroke_front"); - let stroke_front: bool = ui.data(|d| d.get_temp(stroke_front_id)).unwrap_or(false); + let stroke_front = app.ui.stroke_front; let text_mode = crate::panels::colors_affect_text(app) || app.ui.formatting_affects_text; let (fill_sw, stroke_sw) = match &target { Some((fill, _, stroke, _)) => (fill.clone(), stroke.clone()), @@ -215,10 +214,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }) .clicked() { - // Text defaults to a black fill and no stroke; objects to no fill and a black stroke. - let (fill, stroke) = if crate::panels::colors_affect_text(app) { ("[Black]", "[None]") } else { ("[None]", "[Black]") }; - let _ = crate::panels::apply_swatch(app, false, fill, None); - let _ = crate::panels::apply_swatch(app, true, stroke, None); + let _ = app.run("color.defaultFillStroke", json!({})); } let swap = egui::Rect::from_min_size(egui::pos2(x0 + 19.0, row.min.y), vec2(11.0, 11.0)); icons::paint(ui.painter(), swap, "swap", t.icon); @@ -229,8 +225,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }) .clicked() { - let _ = crate::panels::apply_swatch(app, false, &stroke_sw, None); - let _ = crate::panels::apply_swatch(app, true, &fill_sw, None); + let _ = app.run("color.swapFillStroke", json!({})); } ui.add_space(2.0); // Proxy: 19.5 pt squares, fill top-left, stroke bottom-right. @@ -273,16 +268,34 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { crate::rtl::label(ui, fill_tip); }); fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fill_tip)); + let stroke_tip = crate::i18n::tr(&app.ui.language, "Stroke (X)"); let sresp = ui.interact(stroke_r.translate(vec2(0.0, 0.0)), ui.id().with("proxy_stroke"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Stroke (X)")); + crate::rtl::label(ui, stroke_tip); }); + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, stroke_tip)); if fresp.clicked() || sresp.clicked() { let front = if stroke_front { !fresp.clicked() } else { sresp.clicked() && !fill_r.contains(sresp.interact_pointer_pos().unwrap_or_default()) }; - ui.data_mut(|d| d.insert_temp(stroke_front_id, front)); + app.ui.stroke_front = front; } + // A double-click opens the Color Picker for that box (as InDesign's), with the selection's + // colour: the selected text's when text is selected. if fresp.double_clicked() || sresp.double_clicked() { - let _ = app.run("window.panel", json!({"panel": "swatches"})); + let on_fill = fresp.double_clicked() && (!stroke_front || !sresp.double_clicked()); + let stroke = !on_fill; + let text = crate::panels::text_attrs(app).filter(|_| app.session.active().is_some_and(|d| d.selection.text.is_some())); + let current = match text { + Some(a) => a["chars"][if stroke { "stroke" } else { "fill" }].as_str().unwrap_or("[None]").to_string(), + None if stroke => stroke_sw.clone(), + None => fill_sw.clone(), + }; + let rgb = doc.as_ref().and_then(|d| d.resolve_color(¤t, 1.0)).map_or([0.0; 3], |c| c.to_rgb()); + let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let hex = format!("#{:02x}{:02x}{:02x}", byte(rgb[0]), byte(rgb[1]), byte(rgb[2])); + app.ui.dialog = Some(crate::dialogs::Dialog::new( + "colorPicker", + json!({"target": if stroke { "stroke" } else { "fill" }, "hex": hex, "text": crate::panels::colors_affect_text(app)}), + )); } ui.add_space(3.0); // Formatting affects container / text. @@ -318,7 +331,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { crate::widgets::paint_chip(ui.painter(), chip, None, None); flyout_triangle(ui.painter(), well, t.icon); if resp.clicked() { - let _ = crate::panels::apply_swatch(app, stroke_front, "[None]", None); + let _ = app.run("color.applyNone", json!({})); } egui::Popup::context_menu(&resp).show(|ui| { for (label, sw) in [("Apply Color", "[Black]"), ("Apply None", "[None]")] { @@ -411,6 +424,31 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: }) } +/// Default Fill and Stroke (D): no fill, a black stroke. +pub(crate) fn default_fill_stroke(app: &mut DesignApp) -> Result { + if crate::panels::colors_affect_text(app) { + app.run("type.char", json!({"fill": "[Black]", "fillTint": 1.0, "stroke": "[None]", "strokeTint": 1.0})) + } else { + app.run("object.defaultFillStroke", json!({})) + } +} + +/// Swap Fill and Stroke (Shift+X): each selected object's fill and stroke trade places. +pub(crate) fn swap_fill_stroke(app: &mut DesignApp) -> Result { + if crate::panels::colors_affect_text(app) { app.run("type.swapFillStroke", json!({})) } else { app.run("object.swapFillStroke", json!({})) } +} + +/// Fill/Stroke toggle (X): brings the other of fill and stroke to the front of the proxy. +pub(crate) fn toggle_fill_stroke(app: &mut DesignApp) -> Result { + app.ui.stroke_front = !app.ui.stroke_front; + Ok(json!({"strokeFront": app.ui.stroke_front})) +} + +/// Apply None (/): to the fill or the stroke, whichever is in front of the proxy. +pub(crate) fn apply_none(app: &mut DesignApp) -> Result { + crate::panels::apply_swatch(app, app.ui.stroke_front, "[None]", None) +} + #[cfg(test)] mod tests { use egui::{pos2, vec2}; @@ -472,6 +510,36 @@ mod tests { assert!(!h.state().app.ui.tools_double_column); } + #[test] + fn double_clicking_the_fill_box_opens_the_color_picker_with_the_selections_colour() { + let mut app = app(false); + app.run("file.new", serde_json::json!({})).unwrap(); + let id = app.run("frame.create", serde_json::json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); + app.run("object.color", serde_json::json!({"color": "#456daf", "ids": [id]})).unwrap(); + app.run("selection.set", serde_json::json!({"ids": [id]})).unwrap(); + // Frames 1 ms apart, so two clicks make a double-click. + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut crate::DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let at = h.get_by_label(FILL).rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(3); + let d = h.state().ui.dialog.clone().expect("the Color Picker opened"); + assert_eq!(d.id, "colorPicker"); + assert_eq!((d.fields["target"].as_str(), d.fields["hex"].as_str()), (Some("fill"), Some("#456daf")), "{:?}", d.fields); + } + #[test] fn a_tall_window_keeps_one_column_and_does_not_scroll() { let mut h = test_window::open(app(false), vec2(1400.0, 900.0)); @@ -482,4 +550,122 @@ mod tests { wheel(&mut h, panel.center(), vec2(0.0, -2000.0)); assert_eq!((rect(&h, LAST_TOOL), rect(&h, FILL)), before); } + /// The Fill/Stroke proxy's keys run on the selection and stay out of text being typed. + #[test] + fn fill_stroke_proxy_keys() { + use egui::{Event, Key, Modifiers}; + use serde_json::json; + fn press(app: &mut crate::DesignApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::menus::shortcuts(app, ui.ctx())).textures_delta.clear(); + } + fn colors(app: &crate::DesignApp) -> (String, f32, String, f32) { + let st = app.session.active().unwrap(); + let it = st.doc.item(st.selection.items[0]).unwrap(); + (it.fill.swatch.clone(), it.fill.tint, it.stroke.swatch.clone(), it.stroke.tint) + } + let mut app = app(false); + let ctx = egui::Context::default(); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap(); + app.run("selection.set", json!({"ids": [f["id"]]})).unwrap(); + app.run("object.fill", json!({"swatch": "[Paper]", "tint": 0.5})).unwrap(); + app.run("object.stroke", json!({"swatch": "[Black]", "tint": 1.0})).unwrap(); + + press(&mut app, &ctx, Key::X, Modifiers::SHIFT); + assert_eq!(colors(&app), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5), "Shift+X swaps, tints included"); + press(&mut app, &ctx, Key::D, Modifiers::NONE); + assert_eq!(colors(&app), ("[None]".into(), 1.0, "[Black]".into(), 1.0), "D: no fill, black stroke"); + press(&mut app, &ctx, Key::X, Modifiers::NONE); + assert!(app.ui.stroke_front, "X brings the stroke to the front"); + press(&mut app, &ctx, Key::Slash, Modifiers::NONE); + assert_eq!(colors(&app).2, "[None]", "/ applies None to the stroke in front"); + + // Typing in a story: the keys are text, not shortcuts. + app.session.execute("text.select", &json!({"story": f["story"], "anchor": 5})).unwrap(); + app.select_tool("type"); + assert!(app.session.wants_text()); + press(&mut app, &ctx, Key::X, Modifiers::NONE); + assert!(app.ui.stroke_front, "X while typing leaves the proxy alone"); + } +} + +#[cfg(test)] +mod text_color_tests { + use super::*; + use designcraft_doc::{ItemId, StoryId}; + + #[test] + fn integration_color_swap_preserves_each_run_and_frame() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut frames = vec![]; + let mut stories = vec![]; + for text in ["abcd", "efgh"] { + let f = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":text})).unwrap(); + frames.push(f["id"].clone()); + stories.push(f["story"].as_u64().unwrap()); + } + let pairs = [("[Paper]", "[Black]", 0.2, 0.8), ("[Black]", "[Paper]", 0.4, 0.6)]; + for (i, sid) in stories.iter().enumerate() { + for j in 0..2 { + let (fill, stroke, ft, st) = pairs[(i + j) % 2]; + app.run("text.select", json!({"story":sid,"anchor":j*2,"focus":j*2+2})).unwrap(); + app.run("type.char", json!({"fill":fill,"stroke":stroke,"fillTint":ft,"strokeTint":st})).unwrap(); + } + } + app.run("selection.set", json!({"ids":frames})).unwrap(); + app.run("app.formattingAffectsText", json!({"on":true})).unwrap(); + let before = serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(); + let n = app.session.doc().unwrap().history.undo.len(); + swap_fill_stroke(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), n + 1); + for (i, sid) in stories.iter().enumerate() { + let story = app.session.doc().unwrap().doc.story(StoryId(*sid)).unwrap(); + for j in 0..2 { + let (fill, stroke, ft, st) = pairs[(i + j) % 2]; + let a = &story.format_after(j * 2).over; + assert_eq!((a.fill.as_deref(), a.stroke.as_deref(), a.fill_tint, a.stroke_tint), (Some(stroke), Some(fill), Some(st), Some(ft))); + } + } + let swapped = serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(), swapped); + } + + #[test] + fn integration_color_toolbar_picker_targets_selected_frame_text() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":"hello"})).unwrap(); + app.run("selection.set", json!({"ids":[f["id"]]})).unwrap(); + app.run("app.formattingAffectsText", json!({"on":true})).unwrap(); + let before = app.session.doc().unwrap().doc.item(ItemId(f["id"].as_u64().unwrap())).unwrap().fill.clone(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut DesignApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let at = h.get_by_label("Fill (X)").rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(3); + let d = h.state().ui.dialog.clone().unwrap(); + assert_eq!(d.id, "colorPicker"); + assert_eq!(d.fields["text"], true); + h.state_mut().run("object.color", json!({"color":"#123456","target":"fill","text":d.fields["text"]})).unwrap(); + let doc = &h.state().session.doc().unwrap().doc; + assert_eq!(doc.item(ItemId(f["id"].as_u64().unwrap())).unwrap().fill, before); + assert!(doc.story(StoryId(f["story"].as_u64().unwrap())).unwrap().format_after(0).over.fill.is_some()); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b13627c9c..bcb27ddf9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -408,8 +408,10 @@ pub fn selection_band(ui: &mut Ui, title: &str) { pub fn section(ui: &mut Ui, title: &str, _default_open: bool) -> bool { let t = Tokens::get(ui.ctx()); ui.add_space(7.0); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 16.0), Sense::hover()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 16.0), Sense::hover()); crate::rtl::paint(ui.painter(), r.left_center(), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(11.5), t.text_strong); + // Screen readers hear the section's title. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, title)); ui.add_space(3.0); true } @@ -525,6 +527,8 @@ pub fn icon_toggle_sized(ui: &mut Ui, icon: &str, on: bool, tip: &str, size: egu } let s = (size.y - 4.0).min(17.0); crate::icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, if on { t.text_strong } else { t.icon }); + // Named by its tooltip, for screen readers. + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip)); if tip.is_empty() { resp } else { diff --git a/docs/agents.md b/docs/agents.md index 0dd23f062..5115654a2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -21,8 +21,12 @@ Parameters are documented on each command (`params`), e.g. `{anchor? | paragraph ## Scripts: several commands, later ones using earlier results A script is one command per line (`command.id {json params}`; `#` comments), JSON lines, or a JSON array of -`{command, params}`. A parameter string `"$N.path"` becomes that value of step N's result (0-based, `$last` for the -previous step); `"${N.path}"` interpolates inside longer strings. +`{command, params}`, in UTF-8 with or without a byte order mark. A parameter string `"$N.path"` becomes that value +of step N's result: N is the 0-based step index (comments and blank lines are not steps, `$last` is the previous +step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"` +interpolates inside longer strings. In text parameters `\n` starts a new paragraph and `\u2028` is a forced line +break. Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`. +`designcraft-cli script --help` prints this reference. ```sh cat > page.dcs <<'DCS' diff --git a/docs/architecture.md b/docs/architecture.md index 4f06fbcee..39a9039dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DesignCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first committed version, from the code on main and the local plan) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 How DesignCraft is built today. The design notes behind it are in `plan/architecture.md` (local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the @@ -57,8 +57,15 @@ and the control channel all call `Session::execute(id, params)`. UI-only command panels, zoom, dialogs) are `UI_COMMANDS` in `crates/ui-egui/src/menus.rs`, reachable the same way. Tool gestures emit Begin/Preview/Commit commands so a drag is one undo step. Every journaled command is replayable as a script. Escaped panics during a command or an import/export become an -error (`engine/src/guard.rs`), never a lost document; unsaved documents are written to a recovery -folder every 30 s. +error (`engine/src/guard.rs`), never a lost document; native recovery is written periodically +into each running app's locked folder. Orderly native shutdown finalizes complete, current quit +approvals and writes the remaining documents' latest recovery state, including clean-document +cleanup after Undo. Unavailable or failed recovery is reported. The desktop opts into a pinned +winit 0.30 patch that cancels AppKit termination and queues ordinary window close requests, +including Quit from the macOS Dock. The existing unsaved-document transaction can cancel +those requests or exit after current approvals are complete. A pending prompt restores and +focuses a hidden or minimized window; completed and immediate quits do not activate it. The +AppKit callback never runs the confirmation dialogs inside a modal termination loop. ## Composition and rendering @@ -95,4 +102,6 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Documented cancelable Dock Quit through the native event-loop boundary | +| 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap | | 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d82781d43..439987252 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -46,7 +46,8 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.resize`, `ui.focus` | | Window control | | `ui.screenshot` | `{path?}` | PNG of the whole window | | `ui.render` | `{path?, page?, scale?, bleed?}` | Render a page headlessly (PNG; base64 if no path) | -| `app.open` / `app.save` / `app.export` / `app.quit` | | Files | +| `app.open` / `app.save` / `app.export` | | Files | +| `app.quit` | | Quit without asking about unsaved changes (the `app.quit` command, like ⌘Q, asks) | Data merge runs through `engine.execute` like any command: `data.source.select`, `data.fields`, `data.placeholder.add` / `.remove`, `data.options`, `data.preview` / `data.preview.stop`, `data.merge`. `data.merge` creates and activates a new diff --git a/docs/gaps.md b/docs/gaps.md index 31c443d64..41a0af1f3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -143,8 +143,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ## Medium ### 15. Long documents in depth (F) -- **Missing:** Books beyond a list of documents: style/swatch synchronization, book-wide - pagination, book PDF export, book TOC and index; Table of Contents Styles. +- **Missing:** book-wide TOC and index; Table of Contents Styles and deeper production workflows. +- **Present:** basic style synchronization, continuous book numbering and merged PDF export. + Chapter updates prepare the full batch and write only closed native chapters; IDML is a + read-only source. Each file is atomic, but a later I/O failure can leave completed chapters; + the error lists them. Cross-file transactional saving remains unavailable. - **Estimate:** 30–50 h. ### 16. Menu items still missing (U) diff --git a/docs/mcp.md b/docs/mcp.md index 17c03a3df..f35585746 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -58,7 +58,9 @@ so inspect the document before retrying. The next request re-establishes the con Points (1/72 in), y down, in **spread** space: on a single-page spread the page's top-left is (0, 0); on a facing spread the right page starts at x = page width. `inspect_document` lists page bounds per spread. Page indices in -`render_page` / `export_png` are 0-based. `pointer` events in connected mode may use `"space": "screen"` (egui points, +`render_page` / `export_png` are 0-based. Commands that create items take `spread`: a body spread index, or +`{"kind": "parent", "index": 0}` for a parent spread (its index in `parents` of `inspect_document`); any other +value is an error. `pointer` events in connected mode may use `"space": "screen"` (egui points, see `ui_inspect` → `canvasRect`). ## Text offsets diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9457774e4..daaa3f44c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. @@ -276,7 +276,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). -- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. +- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. - Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. - 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. @@ -288,7 +288,10 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). -- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. +- Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. +- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Window close, application/menu Quit and macOS Dock Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate. - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. @@ -350,6 +353,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. - Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. +- Book numbering/style updates validate and serialize all targets before changing the first chapter. They write only closed native chapters, checking canonical paths for open aliases; IDML remains a read-only style source and PDF input. Snapshots are staged on disk with at most 4,096 chapter records in memory. Each replacement is atomic; a later write failure explicitly lists completed chapters rather than claiming whole-book rollback. Import/serialization memory limits and advanced book TOC/index work remain separate. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ee6db4232..8805defce 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -50,7 +50,7 @@ basics of editing fail for too many users on Windows and Linux. | Workspaces | Essentials, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Typography, Touch | ✓ named workspaces + custom | ~ | tab bar overlaps menus in Book/Advanced (#182) | | Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing | | Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | | -| Closing with unsaved changes asks | ✓ | ✓ (#146) | ✓ | | +| Closing with unsaved changes asks | ✓ | ✓ File › Close and window/menu quit (#146, #545) | ✓ | macOS Dock Quit still bypasses the prompt (#544) | | UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | | | Window controls, full screen | ✓ | ~ | ~ | misaligned on macOS (#333) |