Merge origin/main into damaged-image fix; keep the interpolate flag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-10 01:29:50 -04:00
commit fa4c605d8c
68 files changed
+2831 -351

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# DesignCraft — instructions for agents
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app).
+2
View File
@@ -60,3 +60,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | The mark; its outline is drawn in code by `crates/ui-egui/src/about.rs` (About splash, start screen) |
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Mark for light backgrounds |
| `docs/brand/LICENSE-brand.txt` | (licence text) | ArtCraft Team | — | Terms for the ArtCraft Marks in `docs/brand/` |
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the DesignCraft app icon (`assets/app-icon/designcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | DesignCraft macOS DMG window background for the Finder window; `packaging/macos/dmg/generate.py` renders it to `background.tiff` (2026-10-08) |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
+1 -1
View File
@@ -1,6 +1,6 @@
# DesignCraft — instructions for agents
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app).
Generated
+93 -20
View File
@@ -635,6 +635,12 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.5.1"
@@ -855,7 +861,7 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-engine",
"designcraft-ui-egui",
@@ -871,7 +877,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -887,7 +893,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"kurbo",
"moxcms",
@@ -899,7 +905,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -915,7 +921,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-geom",
@@ -927,7 +933,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -952,12 +958,13 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"wasm-bindgen-test",
"zip",
]
[[package]]
name = "designcraft-epub"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -966,7 +973,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@@ -978,7 +985,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-doc",
"serde",
@@ -989,7 +996,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1002,7 +1009,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1014,7 +1021,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-fonts",
@@ -1025,7 +1032,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1038,7 +1045,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1060,7 +1067,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1078,7 +1085,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-doc",
"quick-xml 0.38.4",
@@ -1087,7 +1094,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1099,7 +1106,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1123,12 +1130,14 @@ dependencies = [
[[package]]
name = "designcraft-web"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
"designcraft-ui-egui",
"eframe",
"egui",
"form_urlencoded",
"js-sys",
"log",
"rfd",
@@ -2550,6 +2559,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2673,6 +2692,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3022,6 +3050,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "open"
version = "5.4.4"
@@ -4746,6 +4780,45 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
[[package]]
name = "wayland-backend"
version = "0.3.17"
@@ -5562,7 +5635,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+2 -1
View File
@@ -87,7 +87,8 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or
Line breaks are identical on screen and in PDF.
- **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo
snapshots, and cached composition.
- **Open.** A documented native format, IDML import and export, PNG export, and PDF on the roadmap.
- **Open.** A documented native format, IDML import and export, and PNG, PDF (PDF/X-4, PDF/A-2b)
and EPUB export.
No subscription, no licence server, no telemetry.
- **Agent-native.** Every menu item, tool gesture, panel control and dialog can be driven over a
JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit
+2 -2
View File
@@ -88,7 +88,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items.
- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there).
- 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, `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`). Hyphenation dictionaries for other languages aren't bundled yet.
- 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`). Hyphenation dictionaries for other languages aren'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.
- 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.
@@ -161,7 +161,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML.
- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup.
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.
- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
- Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu.
- Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `<PDF>` in IDML.
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
+32 -2
View File
@@ -1,7 +1,8 @@
//! Headless DesignCraft.
//!
//! ```text
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--pdf-options JSON] [--all-pages DIR]
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--all-pages DIR]
//! # --page, --scale and --pdf-options apply to the exports that follow them
//! designcraft-cli commands [FILTER] # list commands (JSON), optionally only ids/labels/menus containing FILTER
//! designcraft-cli describe ID # one command: label, menu, shortcut, parameters
//! designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
@@ -77,7 +78,7 @@ fn main() -> ExitCode {
}
_ => {
eprintln!(
"usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\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"
"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"
);
eprintln!(
"\nCommunity: {} · {} · {}",
@@ -136,7 +137,36 @@ fn mcp(args: &[String]) -> Result<(), String> {
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
/// The first `--page`, `--scale` or `--pdf-options` with no `--export` or `--all-pages` after it.
/// They apply to the exports that follow them, so one after the last export would do nothing.
fn option_after_last_export(args: &[String]) -> Option<&str> {
let mut unused = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--page" | "--scale" | "--pdf-options" => {
unused = unused.or(Some(a.as_str()));
it.next();
}
"--export" | "--all-pages" => {
unused = None;
it.next();
}
"--in" | "--cmd" => {
it.next();
}
_ => {}
}
}
unused
}
fn run(args: &[String]) -> Result<(), String> {
if let Some(opt) = option_after_last_export(args) {
return Err(format!(
"{opt} has no --export or --all-pages after it; options apply to the exports that follow them, so put it before its --export"
));
}
let mut s = Session::new();
let mut page = 0usize;
let mut scale = 1.0f64;
+22
View File
@@ -80,3 +80,25 @@ fn closed_stdout_ends_quietly() {
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
}
/// `--scale`, `--page` and `--pdf-options` apply to the exports after them; one with no export
/// after it was silently ignored.
#[test]
fn run_export_options_come_before_their_export() {
let dir = std::env::temp_dir().join(format!("dc-cli-order-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (a, b) = (dir.join("a.png"), dir.join("b.png"));
let (a, b) = (a.to_str().unwrap(), b.to_str().unwrap());
for (opt, val) in [("--scale", "0.5"), ("--page", "1"), ("--pdf-options", r#"{"pages":"1"}"#)] {
let out = cli().args(["run", "--sample", "--export", a, opt, val]).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "{opt} after the last --export: {err}");
assert!(err.contains(opt) && err.contains("before"), "{err}");
}
assert!(!std::path::Path::new(a).exists(), "nothing is exported when the arguments are wrong");
let out = cli().args(["run", "--sample", "--scale", "0.5", "--export", a, "--scale", "0.25", "--export", b]).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{err}");
assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
+2
View File
@@ -15,6 +15,8 @@ path = "src/main.rs"
[dependencies]
designcraft-engine = { workspace = true }
designcraft-format = { workspace = true }
form_urlencoded = "1.2"
designcraft-ui-egui = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -0,0 +1,67 @@
//! Pure browser startup and download metadata policies.
/// Query flags are case-sensitive decoded keys. Values are ignored: `?sample=false`
/// still enables sample startup, and repeated keys have the same presence semantics.
pub(crate) fn has_query_flag(query: &str, flag: &str) -> bool {
let query = query.strip_prefix('?').unwrap_or(query);
form_urlencoded::parse(query.as_bytes()).any(|(key, _)| key == flag)
}
pub(crate) fn mime_for(name: &str) -> &'static str {
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
Some("designcraft") => designcraft_format::MIME,
Some("idml") => "application/vnd.adobe.indesign-idml-package",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::{has_query_flag, mime_for};
#[test]
fn startup_flags_match_complete_decoded_keys() {
for flag in ["sample", "webgl"] {
for query in [format!("?{flag}"), format!("?{flag}=false"), format!("?{flag}=1&{flag}=0"), format!("?other=value&{flag}")] {
assert!(has_query_flag(&query, flag), "{query}");
}
for query in [
String::new(),
format!("?note={flag}"),
format!("?{flag}d=1"),
format!("?note=not_a_{flag}"),
"?note=%73ample%77ebgl".to_string(),
format!("?{flag}+extra=1"),
format!("?{flag}%26extra=1"),
format!("?{flag}%3Dextra=1"),
] {
assert!(!has_query_flag(&query, flag), "{query}");
}
}
assert!(has_query_flag("?webgl&sample", "sample"));
assert!(has_query_flag("?webgl&sample", "webgl"));
assert!(has_query_flag("?%73ample", "sample"));
assert!(has_query_flag("?%77ebgl", "webgl"));
assert!(!has_query_flag("?Sample&WEBGL", "sample"));
assert!(!has_query_flag("?Sample&WEBGL", "webgl"));
}
#[test]
fn download_mime_matches_native_package_and_existing_exports() {
for (name, expected) in [
("Quarterly.designcraft", designcraft_format::MIME),
("日本語.multi.DEsignCraft", designcraft_format::MIME),
("page.pdf", "application/pdf"),
("page.png", "image/png"),
("page.jpg", "image/jpeg"),
("page.JPEG", "image/jpeg"),
("document.idml", "application/vnd.adobe.indesign-idml-package"),
("unknown.bin", "application/octet-stream"),
] {
assert_eq!(mime_for(name), expected, "{name}");
}
}
}
+5 -1
View File
@@ -12,9 +12,13 @@
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?sample` opens the
//! sample magazine on start.
//! sample magazine on start. Flags match case-sensitive, percent-decoded query keys; values
//! are ignored and repeated keys have the same presence semantics (`?sample=false` enables it).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
mod browser_policy;
#[cfg(target_arch = "wasm32")]
mod web;
+5 -14
View File
@@ -1,5 +1,6 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use super::browser_policy::{has_query_flag, mime_for};
use designcraft_engine::Session;
use designcraft_ui_egui::{DesignApp, Inbox, Services};
use wasm_bindgen::JsCast as _;
@@ -41,7 +42,7 @@ pub fn start() {
return;
};
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
if has_query_flag(&query(), "webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
@@ -56,7 +57,7 @@ pub fn start() {
}
let inbox: Inbox = Inbox::default();
let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
if query().contains("sample") {
if has_query_flag(&query(), "sample") {
let _ = app.run("file.newSample", serde_json::json!({}));
}
Ok(Box::new(WebShell { app, inbox }))
@@ -103,7 +104,8 @@ impl eframe::App for WebShell {
self.app.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused());
self.app.raw_input_hook(raw);
}
@@ -173,14 +175,3 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
fn mime_for(name: &str) -> &'static str {
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
Some("designcraft") => "application/json",
Some("idml") => "application/vnd.adobe.indesign-idml-package",
_ => "application/octet-stream",
}
}
+2 -2
View File
@@ -25,7 +25,7 @@ impl eframe::App for App {
self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
}
if let Some(m) = &mut self.1 {
m.poll(&mut self.0);
m.poll(&mut self.0, ctx);
}
}
self.0.logic(ctx);
@@ -245,7 +245,7 @@ fn main() -> eframe::Result {
let _ = app.run("file.newSample", serde_json::json!({}));
}
for f in files {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
if let Err(e) = app.open_file("file.open", serde_json::json!({"path": f})) {
eprintln!("designcraft: {f}: {e}");
}
}
+3 -3
View File
@@ -72,10 +72,10 @@ impl NativeMenu {
Self { _menu: menu, items, last_refresh: 0.0 }
}
pub fn poll(&mut self, app: &mut DesignApp) {
pub fn poll(&mut self, app: &mut DesignApp, ctx: &egui::Context) {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) {
designcraft_ui_egui::menus::activate(app, &cmd.clone(), &params.clone());
menus::activate_native(app, ctx, &cmd.clone(), &params.clone());
}
}
let now = designcraft_ui_egui::now_ms();
@@ -84,7 +84,7 @@ impl NativeMenu {
}
self.last_refresh = now;
for (cmd, params, h) in self.items.values() {
let en = menus::menu_enabled(app, cmd);
let en = menus::native_menu_enabled(app, ctx, cmd);
match h {
Handle::Plain(i) => i.set_enabled(en),
Handle::Check(c) => {
+152 -35
View File
@@ -2,49 +2,49 @@
"schema": 1,
"repo": "storytold/designcraft",
"branch": "main",
"head": "f17397c305ecda9d644105f7a4e9b9d65d34bb60",
"head_date": "2026-10-07T18:55:09Z",
"commits": 294,
"head": "66c7ce7e96ec286a091878cc2541ee8369aa79d6",
"head_date": "2026-10-08T13:12:32Z",
"commits": 350,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 22,
"commits": 260,
"lines_added": 99427,
"lines_deleted": 6061,
"prs": 25,
"commits": 270,
"lines_added": 100858,
"lines_deleted": 6338,
"binary_added": 42,
"binary_deleted": 2,
"first_commit": "2026-10-01T13:00:02Z",
"last_commit": "2026-10-07T18:55:09Z"
"last_commit": "2026-10-08T13:50:30Z"
},
{
"login": "storybored379",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 150,
"lines_deleted": 11,
"prs": 5,
"commits": 5,
"lines_added": 181,
"lines_deleted": 14,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-03T16:31:40Z",
"last_commit": "2026-10-05T03:53:26Z"
"last_commit": "2026-10-06T07:03:03Z"
},
{
"login": "CameronGilroy",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 18,
"lines_added": 5236,
"lines_deleted": 381,
"prs": 2,
"commits": 23,
"lines_added": 8982,
"lines_deleted": 609,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T06:15:59Z",
"last_commit": "2026-10-05T18:56:22Z"
"last_commit": "2026-10-07T10:22:06Z"
},
{
"login": "midasdf",
@@ -63,14 +63,14 @@
"login": "ShaoWenbinSaleh",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 6020,
"lines_deleted": 1278,
"prs": 3,
"commits": 10,
"lines_added": 10282,
"lines_deleted": 2843,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T19:15:57Z",
"last_commit": "2026-10-06T14:32:13Z"
"last_commit": "2026-10-07T11:47:38Z"
},
{
"login": "japbcoelho",
@@ -84,6 +84,123 @@
"binary_deleted": 0,
"first_commit": "2026-10-06T01:43:44Z",
"last_commit": "2026-10-06T01:45:41Z"
},
{
"login": "aiongg",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 19,
"lines_added": 5933,
"lines_deleted": 735,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T15:11:56Z",
"last_commit": "2026-10-07T13:18:17Z"
},
{
"login": "epfabian",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 50,
"lines_deleted": 4,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T18:31:52Z",
"last_commit": "2026-10-06T18:31:52Z"
},
{
"login": "EthanSuquet",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 689,
"lines_deleted": 5,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T19:15:09Z",
"last_commit": "2026-10-06T20:13:06Z"
},
{
"login": "Luskish",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 259,
"lines_deleted": 32,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T02:49:05Z",
"last_commit": "2026-10-07T02:49:05Z"
},
{
"login": "denizkoekden",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 7,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T19:23:57Z",
"last_commit": "2026-10-07T19:23:57Z"
},
{
"login": "skylartaylor",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 4,
"lines_added": 528,
"lines_deleted": 28,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T20:27:50Z",
"last_commit": "2026-10-07T22:10:32Z"
},
{
"login": "RicSchonfelder",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1059,
"lines_deleted": 4,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T20:43:34Z",
"last_commit": "2026-10-07T20:43:34Z"
},
{
"login": "h-a-n-a-s-h-i",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 2,
"lines_added": 42,
"lines_deleted": 42,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:13:12Z",
"last_commit": "2026-10-08T14:02:08Z"
},
{
"login": "XusBadia",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 241,
"lines_deleted": 1,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:12:32Z",
"last_commit": "2026-10-08T13:12:32Z"
}
],
"models": [
@@ -92,9 +209,18 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 266,
"lines_added": 83821,
"lines_deleted": 6091
"commits": 282,
"lines_added": 86274,
"lines_deleted": 6420
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 2,
"lines_added": 57,
"lines_deleted": 8
},
{
"id": "anthropic/claude-opus/4.8",
@@ -104,15 +230,6 @@
"commits": 1,
"lines_added": 1633,
"lines_deleted": 3
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 1,
"lines_added": 26,
"lines_deleted": 5
}
]
}
+3
View File
@@ -21,3 +21,6 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.thaynej]
real_name = "Jeffrey Thayne"
+5
View File
@@ -2080,6 +2080,11 @@ fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions:
while j < glyphs.len() && (glyphs[j].is_letter() || glyphs[j].len == 0 || glyphs[j].ch == shape::SOFT_HYPHEN) && !glyphs[j].no_break {
j += 1;
}
// A no-break letter can't start a hyphenatable word.
if j == i {
i += 1;
continue;
}
// A discretionary hyphen in a word (or right before it) replaces automatic hyphenation.
let discretionary = glyphs[i..j].iter().any(|g| g.ch == shape::SOFT_HYPHEN) || (i > 0 && glyphs[i - 1].ch == shape::SOFT_HYPHEN);
let (a, b) = (glyphs[i].byte, glyphs[j - 1].byte + glyphs[j - 1].len);
+12
View File
@@ -313,6 +313,18 @@ fn discretionary_hyphen_replaces_automatic_points() {
assert!(lines.iter().filter(|l| l.hyphenated).count() <= 1);
}
#[test]
fn hyphenation_skips_a_no_break_word_at_the_start() {
// Used to index before the first glyph and panic.
let text = "Extraordinarily long words wrap in a narrow column";
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 60.0, 1000.0), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..15, |f| f.over.no_break = Some(true));
let cs = compose_story(&d, sid, &ComposeOptions::default());
// The no-break word stays whole on the first line.
let first = all_lines(&cs)[0];
assert!(!first.hyphenated && first.range.end >= 15, "{:?}", &text[first.range.clone()]);
}
/// Story of `n` one-line filler paragraphs followed by `extra` paragraphs, in a 2-column frame.
fn keep_doc(fillers: usize, extra: &[&str], height: f64) -> (Document, StoryId, Vec<usize>) {
let mut parts: Vec<String> = (0..fillers).map(|k| format!("Filler {k}")).collect();
+7
View File
@@ -171,11 +171,18 @@ impl Document {
Ok(removed)
}
/// The column direction a new story starts with: right to left in a document whose binding is
/// right to left (Document Setup), else left to right. Stories already made keep theirs.
pub fn new_story_direction(&self) -> crate::TextDirection {
if self.settings.right_to_left_binding { crate::TextDirection::RightToLeft } else { crate::TextDirection::LeftToRight }
}
/// Create a text frame with a new (or given) story at `rect` on spread `r`.
pub fn add_text_frame(&mut self, r: SpreadRef, rect: Rect, layer: LayerId, text: &str, para: ParaFormat) -> Result<(ItemId, StoryId)> {
let id = ItemId(self.alloc());
let sid = StoryId(self.alloc());
let mut story = Story::with_text(sid, text, para);
story.direction = self.new_story_direction();
story.frames.push(id);
self.stories.insert(sid, Arc::new(story));
let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect));
+3
View File
@@ -36,3 +36,6 @@ hayro-syntax = "0.7.2"
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
hayro-cmap = "0.1"
kurbo = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
+37 -2
View File
@@ -124,7 +124,7 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
nd.height = f64_or(p, "height", nd.height);
nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999);
nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages);
nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(nd.columns);
nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns);
nd.gutter = f64_or(p, "gutter", nd.gutter);
nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(false);
match p.get("margins") {
@@ -185,7 +185,7 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c
#[cfg(not(target_arch = "wasm32"))]
{
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
let Some(folder) = std::path::Path::new(path).parent().map(|d| d.join(DOCUMENT_FONTS_FOLDER)) else { return (None, 0, Vec::new()) };
let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) };
let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder);
d.font_scope = r.scope;
let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope)));
@@ -198,6 +198,23 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c
}
}
/// The `Document Fonts` folder in `dir`, matched without regard to case: packages name it
/// `Document fonts`, and file systems on Linux are case-sensitive. The exact name wins.
#[cfg(not(target_arch = "wasm32"))]
fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf {
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
let exact = dir.join(DOCUMENT_FONTS_FOLDER);
if exact.is_dir() {
return exact;
}
std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && e.path().is_dir())
.map_or(exact, |e| e.path())
}
fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let b = base64_decode(str_param(p, "base64").unwrap_or(""));
// IDML packages are recognised by their stored `mimetype` first entry.
@@ -795,6 +812,24 @@ mod document_fonts_tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_document_fonts_folder_is_found_whatever_its_case() {
const FAMILY: &str = "DocFont Case Test";
let dir = std::env::temp_dir().join(format!("dc-open-docfonts-case-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let fonts = dir.join("Document fonts");
std::fs::create_dir_all(&fonts).unwrap();
std::fs::write(fonts.join("case.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap();
let path = dir.join("Brochure.idml");
write_document(&path, FAMILY);
let mut s = Session::new();
let r = open(&mut s, &path);
assert_eq!(r["documentFonts"], 1, "{r}");
assert_eq!(font_entry(&mut s, FAMILY)["missing"], false);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_document_without_the_folder_opens_as_before() {
let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id()));
+19 -3
View File
@@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Guides…",
["Layout"],
None,
"{rows?: 0, columns?: 0, rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)}",
"{rows?: 0, columns?: 0 (at most 100 each), rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)}",
has_doc,
create_guides
),
@@ -157,9 +157,13 @@ pub(crate) fn grid_positions(a: f64, b: f64, n: u32, gutter: f64) -> Vec<f64> {
v
}
/// The most rows or columns Create Guides lays out. The count comes from the caller, and every
/// guide is stored in the document.
const MAX_GUIDE_GRID: u32 = 100;
fn create_guides(s: &mut Session, p: &Value) -> Result<Value> {
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(0) as u32;
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(0) as u32;
let count = |key: &str| p.get(key).and_then(Value::as_u64).unwrap_or(0).min(u64::from(MAX_GUIDE_GRID)) as u32;
let (rows, cols) = (count("rows"), count("columns"));
let rg = p.get("rowGutter").and_then(Value::as_f64).unwrap_or(12.0);
let cg = p.get("columnGutter").and_then(Value::as_f64).unwrap_or(12.0);
let to_page = str_param(p, "fitTo") == Some("page");
@@ -238,6 +242,18 @@ mod tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].guides.len(), 5);
}
/// A huge row or column count once made that many guides (2³² − 1 of them exhaust memory, which
/// no guard catches), and a count past 2³² wrapped to a small one.
#[test]
fn create_guides_caps_the_row_and_column_counts() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let args = json!({"spread": 0, "rows": 4_294_967_298_u64, "columns": 3_000_000, "rowGutter": 0.0, "columnGutter": 0.0});
let r = s.execute("layout.createGuides", &args).unwrap();
let per_page = 2 * (u64::from(MAX_GUIDE_GRID) - 1);
assert_eq!(r["guides"], per_page * s.doc().unwrap().doc.spreads[0].pages.len() as u64);
}
}
#[cfg(test)]
+29
View File
@@ -759,6 +759,35 @@ mod setup_tests {
let d = &s.doc().unwrap().doc;
assert_eq!(d.spreads[1].pages.iter().map(|p| (p.side, p.x)).collect::<Vec<_>>(), vec![(Left, 0.0), (Right, w)]);
}
/// Multi-column frames filled the left column first in a right-to-left document (#100):
/// stories made in a right-to-left-bound document start their columns on the right.
#[test]
fn new_stories_follow_the_binding_direction() {
use designcraft_doc::TextDirection::{LeftToRight, RightToLeft};
let text = (1..=12).map(|i| format!("الفقرة {i}")).collect::<Vec<_>>().join("\n");
for (binding, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap();
s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [40, 170, 555, 260], "content": "text", "text": text})).unwrap();
s.execute("object.textFrameOptions", &json!({"ids": [f["id"]], "columns": 2, "gutter": 20})).unwrap();
let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap());
let st = s.doc().unwrap();
assert_eq!(st.doc.story(sid).unwrap().direction, expected, "{binding}");
let cs = s.cache.get(&st.doc, sid, None);
let fr = &cs.frames[0];
let (first, last) = (fr.lines.first().unwrap(), fr.lines.last().unwrap());
assert!(fr.columns.len() == 2 && first.x0 != last.x0, "the text fills both columns: {:?}", fr.columns);
assert_eq!(first.x0 > last.x0, expected == RightToLeft, "{binding}: the first line is in the first column");
// An empty frame given text content starts a story too.
let g = s.execute("frame.create", &json!({"rect": [40, 400, 555, 500]})).unwrap();
s.execute("object.content", &json!({"ids": [g["id"]], "type": "text"})).unwrap();
let d = &s.doc().unwrap().doc;
let tf = d.item(designcraft_doc::ItemId(g["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
assert_eq!(d.story(tf).unwrap().direction, expected, "{binding}: object.content");
}
}
}
#[cfg(test)]
+77 -2
View File
@@ -4,6 +4,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use designcraft_color::BlendMode;
use designcraft_doc::{
Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, TextSel,
};
@@ -219,7 +220,16 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("object.opacity", "Opacity", [], None, "{opacity: 0..1, blend?: normal|multiply|…, ids?}", has_selection, |s, p| {
let o = f64_or(p, "opacity", 1.0).clamp(0.0, 1.0) as f32;
let blend = p.get("blend").and_then(|b| serde_json::from_value(b.clone()).ok());
// `BlendMode::parse` takes the documented lowercase forms (`multiply`), the panel
// labels (`Color Burn`) and the serialized names (`Multiply`). An unknown value is
// an error rather than a silent no-op that still reports `changed` (#260).
let blend = match p.get("blend") {
None | Some(Value::Null) => None,
Some(v) => {
let raw = v.as_str().ok_or_else(|| bad("object.opacity", "blend must be a string"))?;
Some(BlendMode::parse(raw).ok_or_else(|| bad("object.opacity", format!("unknown blend mode `{raw}`")))?)
}
};
set_flag(
s,
p,
@@ -1379,6 +1389,7 @@ fn content_type(s: &mut Session, p: &Value) -> Result<Value> {
("text", Some(Content::Unassigned | Content::Graphic(_))) => {
let sid = StoryId(d.alloc());
let mut st = Story::new(sid);
st.direction = d.new_story_direction();
st.frames.push(*id);
d.stories.insert(sid, Arc::new(st));
if let Some(it) = d.item_mut(*id) {
@@ -1918,7 +1929,7 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> {
let cols = p.get("columns").and_then(Value::as_u64).map(|v| v as usize);
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize;
let offsets: Vec<Vec2> = match (rows, cols) {
(Some(r), Some(c)) if r * c <= 1000 => (0..r)
(Some(r), Some(c)) if r.checked_mul(c).is_some_and(|n| n <= 1000) => (0..r)
.flat_map(|i| (0..c).map(move |j| (i, j)))
.filter(|&(i, j)| i + j > 0)
.map(|(i, j)| Vec2::new(j as f64 * dx, i as f64 * dy))
@@ -2421,3 +2432,67 @@ mod named_target_tests {
assert_eq!(s.execute("conveyor.collect", &json!({"id": r["id"]})).unwrap()["count"], 1);
}
}
#[cfg(test)]
mod blend_mode_tests {
use designcraft_color::BlendMode;
use super::*;
/// `object.opacity` documents `blend?: normal|multiply|…`, but deserializing the value
/// against the enum only accepted the PascalCase variant names, so the documented
/// lowercase forms were silently dropped: the command reported `changed: 1` while the
/// stored blend stayed as it was (#260).
#[test]
fn lowercase_documented_blend_modes_apply() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let blend = |s: &Session| s.doc().unwrap().doc.item(id).map(|i| i.blend);
for (given, want) in [
("multiply", BlendMode::Multiply),
("screen", BlendMode::Screen),
("MULTIPLY", BlendMode::Multiply),
("Color Burn", BlendMode::ColorBurn),
("color-burn", BlendMode::ColorBurn),
("soft_light", BlendMode::SoftLight),
("normal", BlendMode::Normal),
] {
let out = s.execute("object.opacity", &json!({"opacity": 1, "blend": given})).unwrap();
assert_eq!(out["changed"], 1, "{given}");
assert_eq!(blend(&s), Some(want), "blend mode {given:?} must be applied");
}
}
/// The PascalCase names keep working, so the Properties panel's own submissions are
/// unaffected.
#[test]
fn pascal_case_blend_modes_still_apply() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
s.execute("object.opacity", &json!({"opacity": 0.5, "blend": "Multiply"})).unwrap();
let it = s.doc().unwrap().doc.item(id).cloned().unwrap();
assert_eq!(it.blend, BlendMode::Multiply);
assert!((it.opacity - 0.5).abs() < 1e-6, "opacity must still be set");
}
/// A blend mode the command doesn't know is an error, not a silent success that leaves
/// the previous appearance in place.
#[test]
fn an_unknown_blend_mode_is_an_error() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
s.execute("object.opacity", &json!({"opacity": 1, "blend": "Multiply"})).unwrap();
let e = s.execute("object.opacity", &json!({"opacity": 1, "blend": "sparkle"})).unwrap_err().to_string();
assert!(e.contains("sparkle") && e.contains("blend"), "{e}");
let it = s.doc().unwrap().doc.item(id).cloned().unwrap();
assert_eq!(it.blend, BlendMode::Multiply, "a rejected value changes nothing");
assert_eq!(it.opacity, 1.0);
}
}
+1
View File
@@ -54,6 +54,7 @@ fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
}
let sid = StoryId(d.alloc());
let mut st = Story::with_text(sid, &text, Default::default());
st.direction = d.new_story_direction();
st.frames = vec![id];
d.stories.insert(sid, std::sync::Arc::new(st));
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
+43 -9
View File
@@ -105,27 +105,37 @@ fn strip(story: &mut Story) {
story.format_chars(0..n, |f| *f = Default::default());
}
/// Is the story overset in its frames, and how much of it do its frames show?
fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize) {
/// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)?
/// A frame a column, frame or page break ends early is left out: it says nothing about how much
/// a frame holds.
fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize, usize) {
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
let shown = cs.frames.iter().map(|f| f.range.end).max().unwrap_or(0);
(cs.overset_at, shown)
let text = d.story(sid).map_or("", |s| s.text.as_str());
let is_break = |c: char| matches!(c, designcraft_doc::COLUMN_BREAK | designcraft_doc::FRAME_BREAK | designcraft_doc::PAGE_BREAK);
let (mut held, mut full) = (0, 0);
for f in &cs.frames {
let Some(shown) = text.get(f.range.clone()) else { continue };
if !shown.is_empty() && !shown.chars().any(is_break) {
held += shown.len();
full += 1;
}
}
(cs.overset_at, held, full)
}
/// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result<usize> {
let mut added = 0;
loop {
let (overset, shown) = fit(d, sid);
let (overset, held, full) = fit(d, sid);
let Some(at) = overset else { return Ok(added) };
if added >= max_pages {
return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages")));
}
let len = d.story(sid).map_or(0, |s| s.len());
let frames = d.story(sid).map_or(1, |s| s.frames.len()).max(1);
// Frames still needed at the rate the existing ones are filled (at least one).
let per_frame = (shown.max(at) / frames).max(1);
let want = (len - at).div_ceil(per_frame).clamp(1, max_pages - added);
// Frames still needed at the rate the full ones are filled (at least one); with no full
// frame to go by yet, one more page.
let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added);
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?;
let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1));
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
@@ -200,6 +210,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
Some(_) => {
let sid = StoryId(d.alloc());
let mut ns = Story::new(sid);
ns.direction = d.new_story_direction();
ns.frames = vec![fid];
d.stories.insert(sid, std::sync::Arc::new(ns));
if let Some(it) = d.item_mut(fid) {
@@ -257,6 +268,29 @@ mod tests {
assert_eq!(st.format_after(6).over.font_style.as_deref(), Some("Bold"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_adds_only_the_pages_breaks_ask_for() {
// Regression (#262): a frame a break ends early made autoflow take it for a frame that
// holds a few characters, and add a page per few characters left (33 for this text).
let dir = std::env::temp_dir().join(format!("dc-place-breaks-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (name, rtf, pages) in [
("lead.rtf", r"{\rtf1 \page\par A Short Heading\par First short paragraph.\par Second short paragraph.\par}", 2),
("each.rtf", r"{\rtf1 One\par\page\par Two\par\page\par Three\par\page\par Four\par}", 4),
] {
let path = dir.join(name);
std::fs::write(&path, rtf).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{name}: {r}");
let d = &s.doc().unwrap().doc;
assert_eq!(d.page_count(), pages, "{name}: {r}");
assert_eq!(d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.len(), pages, "{name}");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
+27 -22
View File
@@ -2,8 +2,12 @@
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{EngineError, Result, Session};
#[cfg(not(target_arch = "wasm32"))]
use super::str_param;
use super::{CommandSpec, bad, cmd, has_doc};
#[cfg(not(target_arch = "wasm32"))]
use crate::EngineError;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -39,31 +43,32 @@ fn printers() -> Value {
fn print(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let opts = super::export::options(p, d.page_count())?;
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
let printer = str_param(p, "printer").map(str::to_string);
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_")));
let mut cmd: Vec<String> = vec!["lpr".into()];
if let Some(pr) = &printer {
cmd.extend(["-P".into(), pr.clone()]);
}
if copies > 1 {
cmd.extend(["-#".into(), copies.to_string()]);
}
cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]);
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
if dry {
return Ok(out);
}
#[cfg(target_arch = "wasm32")]
{
let _ = pdf;
// Even a dry run cannot construct a native spool command in the browser.
let _ = opts;
Err(bad("file.print", "printing isn't available on the web: export a PDF and print it"))
}
#[cfg(not(target_arch = "wasm32"))]
{
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
let printer = str_param(p, "printer").map(str::to_string);
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_")));
let mut cmd: Vec<String> = vec!["lpr".into()];
if let Some(pr) = &printer {
cmd.extend(["-P".into(), pr.clone()]);
}
if copies > 1 {
cmd.extend(["-#".into(), copies.to_string()]);
}
cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]);
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
if dry {
return Ok(out);
}
std::fs::write(&file, &pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.display())))?;
let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?;
if !st.status.success() {
@@ -73,7 +78,7 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
}
}
#[cfg(test)]
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use serde_json::json;
+54 -1
View File
@@ -372,6 +372,14 @@ fn column_width(s: &Session, t: &TextSel) -> f64 {
spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0)
}
/// The direction of the paragraph at `offset` in `story`, as composition resolves it (its style
/// chain, then local overrides). A table created there takes it.
fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_doc::TextDirection {
d.story(story)
.and_then(|st| st.paras.get(st.para_at(offset)))
.map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction)
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?;
if t.cell.is_some() {
@@ -385,6 +393,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(|d, sel| {
let id = d.alloc();
let mut table = Table::new(id, rows, cols, header, footer, width);
table.options.direction = para_direction(d, t.story, t.range().start);
let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?;
// Cells start with the paragraph format at the caret.
let pf = st.paras[st.para_at(t.range().start)].clone();
@@ -410,6 +419,7 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
s.edit(|d, sel| {
let id = d.alloc();
let direction = para_direction(d, t.story, t.range().start);
let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?;
let ranges = st.para_ranges();
let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end));
@@ -420,7 +430,8 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
let data: Vec<Vec<String>> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect();
let pf = st.paras[pa].clone();
let cf = st.format_after(a).clone();
let table = Table::from_strings(id, &data, width, &pf, &cf);
let mut table = Table::from_strings(id, &data, width, &pf, &cf);
table.options.direction = direction;
st.delete(a..b);
let anchor = st.insert_table(a, table);
*sel = Selection::text(TextSel::caret(t.story, anchor));
@@ -1435,4 +1446,46 @@ mod arabic_tests {
assert_eq!(t.ncols(), 2);
assert_eq!(t.options.direction, designcraft_doc::TextDirection::RightToLeft);
}
/// A text frame whose paragraphs have a paragraph style with `direction`, with the story's
/// text selected. Returns the session and the story id.
fn styled_frame(direction: &str, text: &str) -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Dir", "para": {"direction": direction}})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [40, 330, 555, 420], "content": "text", "text": text, "caret": true})).unwrap();
s.execute("edit.selectAll", &json!({})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Dir"})).unwrap();
s.execute("edit.selectAll", &json!({})).unwrap();
(s, r["story"].as_u64().unwrap())
}
fn only_table(s: &Session) -> std::sync::Arc<designcraft_doc::Table> {
s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap().clone()
}
/// Text converted to a table in a right-to-left paragraph laid its columns out left to right
/// (#100): the first column belongs on the right.
#[test]
fn tables_take_the_direction_of_their_paragraph() {
use designcraft_doc::TextDirection::{LeftToRight, RightToLeft};
let (mut s, sid) = styled_frame("rightToLeft", "١\t٢\t٣\nأ\tب\tج");
s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap();
assert_eq!(only_table(&s).options.direction, RightToLeft);
let st = s.doc().unwrap();
let cs = s.cache.get(&st.doc, designcraft_doc::StoryId(sid), None);
let placed = &cs.frames[0].tables[0];
let (first, last) = (placed.cell(0, 0).unwrap().rect, placed.cell(0, 2).unwrap().rect);
assert!(first.x0 > last.x0, "cell ١ is the rightmost: {first:?} {last:?}");
let (mut s, _) = styled_frame("leftToRight", "1\t2\t3\na\tb\tc");
s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap();
assert_eq!(only_table(&s).options.direction, LeftToRight);
for (direction, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] {
let (mut s, _) = styled_frame(direction, "x");
s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();
assert_eq!(only_table(&s).options.direction, expected, "table.insert in a {direction} paragraph");
}
}
}
+17 -1
View File
@@ -114,8 +114,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| {
let st = s.doc()?;
let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?;
let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let cs = s.cache.get(&st.doc, sid, None);
let mut v = st.doc.story_summary(sid).unwrap_or_default();
v["overset"] = json!(cs.overset_at);
v["lines"] = json!(cs.line_count());
Ok(v)
@@ -1638,3 +1638,19 @@ mod vertical_caret_tests {
assert_eq!(mv(&mut s, "up", false, false), 3 * c);
}
}
#[cfg(test)]
mod story_query_contract_tests {
use serde_json::json;
#[test]
fn missing_story_errors_but_valid_empty_and_selected_stories_succeed() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
assert!(s.execute("story.get", &json!({"story":999})).unwrap_err().to_string().contains("no such story"));
let f = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text"})).unwrap();
assert_eq!(s.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "");
assert_eq!(s.execute("story.get", &json!({"frame":f["id"]})).unwrap()["text"], "");
s.execute("text.select", &json!({"story":f["story"],"anchor":0})).unwrap();
assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], "");
}
}
+22
View File
@@ -372,6 +372,28 @@ fn step_and_repeat_grid() {
assert_eq!(last.y0, 36.0 + 2.0 * 40.0);
}
/// rows × columns once overflowed: a panic in debug builds, and in release builds a product that
/// wrapped to 0 passed the 1000-copy limit and started a grid of 2⁶⁴ copies.
#[test]
fn step_and_repeat_with_a_huge_grid_makes_no_grid() {
let mut s = session();
s.execute("frame.create", &json!({"rect": [36, 36, 66, 66], "content": "unassigned"})).unwrap();
let r = s.execute("edit.stepAndRepeat", &json!({"rows": 4_294_967_296_u64, "columns": 4_294_967_296_u64})).unwrap();
// As for any grid of more than 1000 copies: `count` (default 1) copies instead.
assert_eq!(r["created"], 1);
}
/// The column count is the caller's and sizes a list every time the page's columns are laid out.
#[test]
fn new_document_caps_the_column_count() {
let mut s = Session::new();
s.execute("file.new", &json!({"columns": 4_000_000_000_u64})).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].columns.count, 216);
// Past u32 the count wrapped (2³² + 3 became 3).
s.execute("file.new", &json!({"columns": 4_294_967_299_u64})).unwrap();
assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].columns.count, 216);
}
#[test]
fn snippets_roundtrip_between_documents() {
let mut s = Session::new();
+31
View File
@@ -0,0 +1,31 @@
//! Browser Print must reject native spooler operations without trapping the editor.
#![cfg(target_arch = "wasm32")]
use designcraft_engine::Session;
use serde_json::json;
use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure};
wasm_bindgen_test_configure!(run_in_browser);
fn print_is_safe(dry_run: bool) {
let mut session = Session::new();
session.execute("file.new", &json!({"pages": 1})).unwrap();
let error = session.execute("file.print", &json!({"dryRun": dry_run})).unwrap_err().to_string();
assert!(error.contains("printing isn't available on the web"), "{error}");
assert!(error.contains("export a PDF"), "{error}");
session.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text"})).unwrap();
let exported = session.execute("file.exportPdf", &json!({})).unwrap();
assert!(exported["bytes"].as_u64().unwrap() > 500);
assert_eq!(exported["pages"], 1);
assert!(session.execute("file.print", &json!({"pages": "9", "dryRun": dry_run})).is_err());
}
#[wasm_bindgen_test]
fn print_confirmation_preserves_editing_and_pdf_export() {
print_is_safe(false);
}
#[wasm_bindgen_test]
fn print_dry_run_is_also_unsupported_without_native_operations() {
print_is_safe(true);
}
+191 -42
View File
@@ -39,7 +39,7 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
let io = |e: std::io::Error| EpubError(e.to_string());
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let lang = &opts.language;
let lang = language_tag(&opts.language);
let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title)));
let mut manifest = String::from("<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n");
let mut spine = String::new();
@@ -50,8 +50,8 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
let (w, h) = (p.width.round().max(1.0) as u32, p.height.round().max(1.0) as u32);
let xhtml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"{lang}\" lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width={w}, height={h}\"/><title>{} — {n}</title>\n<style>body{{margin:0;width:{w}px;height:{h}px;position:relative}} img{{position:absolute;left:0;top:0;width:{w}px;height:{h}px}} .t{{position:absolute;left:0;top:0;width:{w}px;color:transparent;font-size:1px;overflow:hidden}}</style></head>\n<body><img src=\"pages/p{n}.png\" alt=\"\"/><div class=\"t\">{}</div></body>\n</html>\n",
esc(&title),
esc(&p.text).replace('\n', "<br/>")
xml_text(&title),
story_markup(&p.text).replace('\n', "<br/>")
);
let _ = writeln!(
manifest,
@@ -66,13 +66,13 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions
let (vw, vh) = pages.first().map_or((612, 792), |p| (p.width.round() as u32, p.height.round() as u32));
let nav = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title></head>\n<body><nav epub:type=\"page-list\" id=\"toc\"><h1>Pages</h1><ol>{nav_items}</ol></nav></body>\n</html>\n",
esc(&title)
xml_text(&title)
);
let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", esc(a))).unwrap_or_default();
let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
let opf = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\" prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">2026-01-01T00:00:00Z</meta>\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</spine>\n</package>\n",
esc(&id),
esc(&title)
xml_text(&id),
xml_text(&title)
);
let container = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\n";
let mut buf = std::io::Cursor::new(Vec::new());
@@ -114,7 +114,11 @@ impl std::fmt::Display for EpubError {
}
impl std::error::Error for EpubError {}
fn esc(s: &str) -> String {
fn valid_xml_char(c: char) -> bool {
matches!(c, '\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
}
fn escape_markup(s: &str, attr: bool, story_breaks: bool) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
@@ -122,15 +126,81 @@ fn esc(s: &str) -> String {
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\'' if attr => o.push_str("&apos;"),
'\u{E000}'..='\u{E1FF}' => {}
'\u{2028}' => o.push_str("<br/>"),
'\u{2028}' if story_breaks => o.push_str("<br/>"),
'\u{AD}' => o.push_str("&#173;"),
c => o.push(c),
c if valid_xml_char(c) => o.push(c),
_ => {}
}
}
o
}
fn xml_text(s: &str) -> String {
escape_markup(s, false, false)
}
fn xml_attr(s: &str) -> String {
escape_markup(s, true, false)
}
/// Story content permits the document's discretionary line-separator marker to become markup.
/// Other contexts always serialize it as ordinary text.
fn story_markup(s: &str) -> String {
escape_markup(s, false, true)
}
/// A conservative BCP 47 subset suitable for both HTML and EPUB language attributes.
fn language_tag(s: &str) -> &str {
if s.len() > 63 {
return "und";
}
let mut parts = s.split('-');
let Some(first) = parts.next() else { return "und" };
if !(2..=8).contains(&first.len()) || !first.bytes().all(|b| b.is_ascii_alphabetic()) {
return "und";
}
if parts.any(|p| p.is_empty() || p.len() > 8 || !p.bytes().all(|b| b.is_ascii_alphanumeric())) {
return "und";
}
s
}
/// Return a MIME type that is safe to place in an attribute and a data URL. Parameters are omitted
/// because exported assets are always represented as their original binary bytes.
fn safe_mime(mime: &str) -> &str {
fn token(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'+' | b'-'))
}
let mime = mime.split_once(';').map_or(mime, |(essence, _)| essence).trim();
if mime.len() <= 127
&& let Some((kind, subtype)) = mime.split_once('/')
&& !subtype.contains('/')
&& token(kind)
&& token(subtype)
{
mime
} else {
"image/png"
}
}
/// Quote arbitrary document metadata as one CSS string token. Escaping `<` is important because
/// HTML parses the contents of a `style` element before CSS parsing sees it.
fn css_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '"' | '\\' | '<' | '>' | '&') || c.is_control() {
let _ = write!(out, "\\{:x} ", c as u32);
} else {
out.push(c);
}
}
out
}
/// CSS class name for a style name.
pub fn slug(name: &str) -> String {
let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
@@ -177,7 +247,7 @@ fn hex(doc: &Document, swatch: &str, tint: f32) -> Option<String> {
}
fn char_css(doc: &Document, c: &CharProps, base_size: f64, out: &mut String) {
let _ = write!(out, "font-family: \"{}\", serif; ", c.font_family);
let _ = write!(out, "font-family: \"{}\", serif; ", css_string(&c.font_family));
let st = c.font_style.to_lowercase();
if st.contains("bold") {
out.push_str("font-weight: bold; ");
@@ -330,7 +400,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
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 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=\"{class}\"{}>", if rtl { " dir=\"rtl\"" } else { "" });
let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" });
if text_empty {
out.push_str("&#160;");
}
@@ -353,9 +423,9 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
None => c.to_string(),
})
.collect();
esc(&sub)
story_markup(&sub)
} else {
esc(raw)
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");
@@ -365,12 +435,12 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
.filter(|c| !c.is_empty())
.or_else(|| cc.get(f.style.as_str()).cloned())
.unwrap_or_else(|| slug(&f.style));
format!(" class=\"{class}\"")
format!(" class=\"{}\"", xml_attr(&class))
} else {
String::new()
};
let style = override_css(doc, &f.over);
let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{style}\"") };
let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) };
if cls.is_empty() && style_attr.is_empty() {
out.push_str(&t);
} else {
@@ -495,10 +565,11 @@ fn body_html<'a>(
if a.data.is_empty() {
continue;
}
let mime = safe_mime(&a.mime);
// Video and sound: an HTML5 player (with the poster, when there is one).
if let Some(kind) = designcraft_doc::media_kind(&a.mime) {
let name = format!("media/{}.{}", g.asset.0, ext(&a.mime));
images.insert(name.clone(), (a.mime.as_str(), a.data.as_slice()));
if let Some(kind) = designcraft_doc::media_kind(mime) {
let name = format!("media/{}.{}", g.asset.0, ext(mime));
images.insert(name.clone(), (mime, a.data.as_slice()));
let m = doc.item(*iid).and_then(|i| i.media.clone()).unwrap_or_default();
let mut attrs = String::new();
for (on, attr) in
@@ -509,17 +580,23 @@ fn body_html<'a>(
}
}
if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) {
let pn = format!("images/{}.{}", pa.id.0, ext(&pa.mime));
images.insert(pn.clone(), (pa.mime.as_str(), pa.data.as_slice()));
let poster_mime = safe_mime(&pa.mime);
let pn = format!("images/{}.{}", pa.id.0, ext(poster_mime));
images.insert(pn.clone(), (poster_mime, pa.data.as_slice()));
if kind == "video" {
attrs.push_str(&format!(" poster=\"{}\"", img_src(&pn, &pa.mime, &pa.data)));
attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data))));
}
}
let _ = writeln!(body, "<figure><{kind} src=\"{}\"{attrs}>{}</{kind}></figure>", img_src(&name, &a.mime, &a.data), esc(&a.name));
let _ = writeln!(
body,
"<figure><{kind} src=\"{}\"{attrs}>{}</{kind}></figure>",
xml_attr(&img_src(&name, mime, &a.data)),
xml_text(&a.name)
);
continue;
}
let name = format!("images/{}.{}", g.asset.0, ext(&a.mime));
images.insert(name.clone(), (a.mime.as_str(), a.data.as_slice()));
let name = format!("images/{}.{}", g.asset.0, ext(mime));
images.insert(name.clone(), (mime, a.data.as_slice()));
// Alt text from Object Export Options, else the file name.
let alt = doc.item(*iid).map(|i| i.alt_text.as_str()).filter(|t| !t.is_empty()).unwrap_or(&a.name);
// Object Export Options: alignment and a page break before.
@@ -531,8 +608,9 @@ fn body_html<'a>(
if eo.page_break_before {
css.push_str("page-break-before:always;break-before:page;");
}
let style = if css.is_empty() { String::new() } else { format!(" style=\"{css}\"") };
let _ = writeln!(body, "<figure{style}><img src=\"{}\" alt=\"{}\"/></figure>", img_src(&name, &a.mime, &a.data), esc(alt));
let style = if css.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&css)) };
let _ =
writeln!(body, "<figure{style}><img src=\"{}\" alt=\"{}\"/></figure>", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(alt));
}
}
}
@@ -550,11 +628,12 @@ pub struct HtmlOptions {
/// document's stories and graphics in reading order.
pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String {
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let lang = opts.language.clone().unwrap_or_else(|| "en".into());
let language = opts.language.as_deref().unwrap_or("en");
let lang = language_tag(language);
let (body, _, _) = body_html(doc, &|_, mime, data| format!("data:{mime};base64,{}", base64(data)));
format!(
"<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"generator\" content=\"DesignCraft\">\n<title>{}</title>\n<style>\nbody {{ max-width: 40em; margin: 2em auto; padding: 0 1em; }}\nfigure {{ margin: 1em 0; }} figure img {{ max-width: 100%; height: auto; }}\n{}</style>\n</head>\n<body>\n{body}</body>\n</html>\n",
esc(&title),
xml_text(&title),
stylesheet(doc)
)
}
@@ -578,38 +657,38 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title)));
let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string());
let lang = &opts.language;
let lang = language_tag(&opts.language);
let chapter = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\" lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title><link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/></head>\n<body>\n{body}</body>\n</html>\n",
esc(&title)
xml_text(&title)
);
let mut nav_items = String::new();
for (a, label) in &toc {
let _ = write!(nav_items, "<li><a href=\"content.xhtml#{a}\">{}</a></li>", esc(label));
let _ = write!(nav_items, "<li><a href=\"content.xhtml#{a}\">{}</a></li>", xml_text(label));
}
if nav_items.is_empty() {
nav_items = format!("<li><a href=\"content.xhtml\">{}</a></li>", esc(&title));
nav_items = format!("<li><a href=\"content.xhtml\">{}</a></li>", xml_text(&title));
}
let nav = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title></head>\n<body><nav epub:type=\"toc\" id=\"toc\"><h1>Contents</h1><ol>{nav_items}</ol></nav></body>\n</html>\n",
esc(&title)
xml_text(&title)
);
let mut manifest = String::from(
"<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n<item id=\"css\" href=\"style.css\" media-type=\"text/css\"/>\n<item id=\"content\" href=\"content.xhtml\" media-type=\"application/xhtml+xml\"/>\n",
);
for (k, (name, (mime, _))) in images.iter().enumerate() {
let _ = writeln!(manifest, "<item id=\"img{k}\" href=\"{name}\" media-type=\"{mime}\"/>");
let _ = writeln!(manifest, "<item id=\"img{k}\" href=\"{}\" media-type=\"{}\"/>", xml_attr(name), xml_attr(mime));
}
// Cover: an image page first in reading order.
if opts.cover.is_some() {
manifest.push_str("<item id=\"cover-img\" href=\"images/cover.png\" media-type=\"image/png\" properties=\"cover-image\"/>\n<item id=\"cover\" href=\"cover.xhtml\" media-type=\"application/xhtml+xml\"/>\n");
}
let cover_spine = if opts.cover.is_some() { "<itemref idref=\"cover\"/>\n" } else { "" };
let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", esc(a))).unwrap_or_default();
let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
let opf = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">2026-01-01T00:00:00Z</meta>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{cover_spine}<itemref idref=\"content\"/>\n</spine>\n</package>\n",
esc(&id),
esc(&title)
xml_text(&id),
xml_text(&title)
);
let container = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\n";
let mut buf = std::io::Cursor::new(Vec::new());
@@ -638,8 +717,8 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr
z.write_all(c).map_err(io)?;
let xhtml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title><style>body{{margin:0;text-align:center}} img{{max-width:100%;max-height:100vh}}</style></head>\n<body epub:type=\"cover\"><img src=\"images/cover.png\" alt=\"{}\"/></body>\n</html>\n",
esc(&title),
esc(&title)
xml_text(&title),
xml_attr(&title)
);
z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?;
z.write_all(xhtml.as_bytes()).map_err(io)?;
@@ -653,9 +732,10 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr
mod tests {
use super::*;
use designcraft_doc::build::NewDocument;
use designcraft_doc::geom::Rect;
use designcraft_doc::{ParaFormat, SpreadRef};
use designcraft_doc::geom::{Affine, Rect, shapes};
use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef};
use std::io::Read;
use std::sync::Arc;
#[test]
fn exports_valid_package_in_reading_order() {
@@ -734,4 +814,73 @@ mod tests {
assert_eq!(base64(b"Ma"), "TWE=");
assert_eq!(base64(b"M"), "TQ==");
}
#[test]
fn metadata_is_serialized_for_its_output_context() {
let mut d = Document::new(&NewDocument::default());
d.title = "Field Notes & <Edition>".into();
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B <C> \"D\"", ParaFormat::default()).unwrap();
d.styles_mut()
.export_tags
.insert(format!("p:{}", story::BASIC_PARAGRAPH), designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into() });
if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) {
basic.chars.font_family = Some("Quoted \"Serif\" <Alt>".into());
}
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
page: 0,
id: aid,
name: "Cover & Notes".into(),
mime: "image/png; profile=screen".into(),
link: None,
data: Arc::new(vec![1]),
pixels: Some((1, 1)),
}),
);
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0)));
item.alt_text = "Cover \"A\" & B".into();
item.content = Content::Graphic(Graphic {
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
});
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
let story = story_html(&d, sid);
assert!(story.contains("<h2 class=\"Primary &quot;Copy&quot;\">A&amp;B &lt;C&gt; &quot;D&quot;</h2>"));
let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()) });
assert!(html.contains("<html lang=\"und\">"));
assert!(html.contains("<title>Field Notes &amp; &lt;Edition&gt;</title>"));
assert!(html.contains("alt=\"Cover &quot;A&quot; &amp; B\""));
assert!(html.contains("src=\"data:image/png;base64,AQ==\""));
assert!(!html.contains("image/png; profile=screen"));
assert!(!stylesheet(&d).contains("<Alt>"));
assert!(stylesheet(&d).contains("\\3c Alt\\3e "));
let bytes = export_epub(
&d,
&EpubOptions {
title: None,
author: Some("A & B".into()),
language: "English (US)".into(),
identifier: Some("notes&edition".into()),
cover: None,
},
)
.unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut opf = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
assert!(opf.contains("xml:lang=\"und\""));
assert!(opf.contains("<dc:identifier id=\"bookid\">notes&amp;edition</dc:identifier>"));
assert!(opf.contains("<dc:creator>A &amp; B</dc:creator>"));
assert!(opf.contains("media-type=\"image/png\""));
}
}
+5 -4
View File
@@ -29,14 +29,15 @@ pub fn font_mapping(family: &str, glyphs: &[(char, char)]) -> Option<Vec<u8>> {
let mut tables = tables_of(base)?;
tables.retain(|(tag, _)| tag != b"name" && tag != b"cmap");
// `name`: format 0, family (1) and subfamily (2), Windows Unicode English.
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect();
// `name`: format 0, family (1), subfamily (2) and PostScript name (6), Windows Unicode English.
let ps: String = family.chars().filter(char::is_ascii_alphanumeric).chain("-Regular".chars()).collect();
let strings: Vec<Vec<u8>> = [family, "Regular", &ps].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect();
let mut name = Vec::new();
for v in [0u16, 2, 6 + 2 * 12] {
for v in [0u16, 3, 6 + 3 * 12] {
name.extend_from_slice(&v.to_be_bytes());
}
let mut at = 0usize;
for (id, s) in [1u16, 2].iter().zip(&strings) {
for (id, s) in [1u16, 2, 6].iter().zip(&strings) {
for v in [3u16, 1, 0x409, *id, u16::try_from(s.len()).ok()?, u16::try_from(at).ok()?] {
name.extend_from_slice(&v.to_be_bytes());
}
+3
View File
@@ -14,3 +14,6 @@ designcraft-fonts = { workspace = true }
image = { workspace = true }
tiff = { workspace = true }
resvg = { workspace = true }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }
+97 -3
View File
@@ -1,7 +1,7 @@
//! Placed graphics: format sniffing, sizes and decoding for the renderer and PDF export.
//! Rasters go through `image` (PNG, JPEG, GIF, WebP, TIFF, BMP) or [`psd`] (Photoshop's merged
//! composite); SVG is parsed with usvg (text set in the bundled fonts) and rasterised with resvg
//! for the screen — PDF export draws the same tree as vectors.
//! composite); SVG is parsed with usvg (text set in the bundled fonts, then the shared ones) and
//! rasterised with resvg for the screen — PDF export draws the same tree as vectors.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::sync::{Arc, OnceLock};
@@ -67,10 +67,67 @@ fn svg_options() -> &'static usvg::Options<'static> {
db.set_serif_family("Source Serif 4");
db.set_sans_serif_family("Source Sans 3");
db.set_monospace_family("JetBrains Mono");
usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), ..Default::default() }
let font_resolver = usvg::FontResolver { select_font: select_font(), select_fallback: select_fallback() };
usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), font_resolver, ..Default::default() }
})
}
/// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too),
/// as it would in a text frame.
fn select_font() -> usvg::FontSelectionFn<'static> {
let bundled = usvg::FontResolver::default_font_selector();
Box::new(move |font, db| {
let fonts = designcraft_fonts::FontDb::global();
for family in font.families() {
if let usvg::FontFamily::Named(name) = family
&& !db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)))
&& fonts.has_family(name)
{
for style in fonts.styles(name) {
add_face(db, fonts.face(name, &style));
}
}
}
bundled(font, db)
})
}
/// Characters the chosen fonts lack (Korean, Arabic…) fall back to a shared face that has them.
fn select_fallback() -> usvg::FallbackSelectionFn<'static> {
let loaded = usvg::FontResolver::default_fallback_selector();
Box::new(move |c, exclude, db| {
loaded(c, exclude, db).or_else(|| {
let id = add_face(db, designcraft_fonts::FontDb::global().fallback_for(c, 0, None)?)?;
// usvg asks again until we run out: a face it already tried ends the search.
(!exclude.contains(&id)).then_some(id)
})
})
}
/// A shared face's bytes, handed to usvg without a copy.
struct FaceBytes(Arc<designcraft_fonts::FontFace>);
impl AsRef<[u8]> for FaceBytes {
fn as_ref(&self) -> &[u8] {
self.0.data()
}
}
/// Add `face` to the SVG's font database (once) and return its id there.
fn add_face(db: &mut Arc<usvg::fontdb::Database>, face: Arc<designcraft_fonts::FontFace>) -> Option<usvg::fontdb::ID> {
let same = |f: &usvg::fontdb::FaceInfo| {
f.index == face.index()
&& matches!(&f.source, usvg::fontdb::Source::Binary(b) if std::ptr::eq(b.as_ref().as_ref().as_ptr(), face.data().as_ptr()))
};
if let Some(f) = db.faces().find(|f| same(f)) {
return Some(f.id);
}
let index = face.index();
let ids = Arc::make_mut(db).load_font_source(usvg::fontdb::Source::Binary(Arc::new(FaceBytes(face))));
// A collection loads all its faces (skipping any it can't read): pick ours by its index.
ids.into_iter().find(|id| db.face(*id).is_some_and(|f| f.index == index))
}
/// The parsed SVG.
pub fn svg_tree(bytes: &[u8]) -> Option<usvg::Tree> {
usvg::Tree::from_data(bytes, svg_options()).ok()
@@ -211,6 +268,43 @@ mod tests {
assert!(!is_svg(b"\x89PNG"));
}
/// The family of the font that sets each text glyph of `svg`, and the glyph's id.
fn glyph_families(svg: &str) -> Vec<(String, u16)> {
fn walk(g: &usvg::Group, tree: &usvg::Tree, out: &mut Vec<(String, u16)>) {
for n in g.children() {
match n {
usvg::Node::Group(g) => walk(g, tree, out),
usvg::Node::Text(t) => {
for g in t.layouted().iter().flat_map(|s| &s.positioned_glyphs) {
out.extend(tree.fontdb().face(g.font).map(|f| (f.families[0].0.clone(), g.id.0)));
}
}
_ => {}
}
}
}
let tree = svg_tree(svg.as_bytes()).unwrap();
let mut out = Vec::new();
walk(tree.root(), &tree, &mut out);
out
}
#[test]
fn svg_text_uses_the_shared_fonts() {
let font = designcraft_fonts::testing::font_with("DC Test SVG Hangul", &['한', '글']).unwrap();
designcraft_fonts::FontDb::global().add_font(font);
let text = |attrs: &str, body: &str| {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40"><text y="30" font-size="20"{attrs}>{body}</text></svg>"#)
};
let named = r#" font-family="DC Test SVG Hangul""#;
// A family named in the SVG.
let set = glyph_families(&text(named, "한글"));
assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Hangul" && *gid != 0), "{set:?}");
// Characters the bundled default lacks: a shared face that has them, not the default.
let set = glyph_families(&text("", "한글"));
assert!(set.len() == 2 && set.iter().all(|(f, gid)| f != "Source Sans 3" && *gid != 0), "{set:?}");
}
/// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image.
fn tiny_psd() -> Vec<u8> {
let mut b = Vec::new();
+2 -3
View File
@@ -144,8 +144,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let byte = |v: f32| v.round().clamp(0.0, 255.0) as u8;
let white = mode.white();
for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
let Ok(px) = <&mut [u8; 4]>::try_from(px) else { continue };
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let a = alpha.get(i).copied().unwrap_or(255);
let mut v = px.map(f32::from);
// The composite is matted against white where it's partly transparent: un-matte it.
@@ -314,7 +313,7 @@ fn fill(dst: &mut [u8], slot: usize, step: usize, src: &[u8], depth: u16) {
match depth {
1 => dst.zip(src.iter().flat_map(|&b| (0..8).rev().map(move |i| if (b >> i) & 1 == 1 { 0 } else { 255 }))).for_each(|(d, s)| *d = s),
16 => dst
.zip(src.chunks_exact(2).map(|c| c.iter().fold(0u32, |n, &b| n << 8 | u32::from(b))))
.zip(src.as_chunks::<2>().0.iter().map(|c| c.iter().fold(0u32, |n, &b| n << 8 | u32::from(b))))
.for_each(|(d, s)| *d = ((s * 255 + 32_767) / 65_535) as u8),
_ => dst.zip(src).for_each(|(d, &s)| *d = s),
}
+15 -3
View File
@@ -180,7 +180,7 @@ impl Headless {
fn text(&mut self, p: &Value) -> Result<Value, String> {
let t = s(p, "text").ok_or("missing `text`")?;
if !self.session.wants_text() {
if self.session.active().is_none_or(|st| st.selection.text.is_none_or(|t| st.doc.text_story(t.story, t.cell).is_none())) {
return Err("no text insertion point: click into a text frame with the type tool first (pointer with tool \"type\"), \
or select text with execute text.select, or use set_story_text"
.into());
@@ -191,7 +191,7 @@ impl Headless {
fn render(&mut self, p: &Value) -> Result<Value, String> {
let st = self.session.active().ok_or("no document open")?;
let page = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize;
let page = page_index(p, st.doc.page_count())?;
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.05, 16.0);
let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false);
let img = self
@@ -213,7 +213,7 @@ impl Headless {
fn export(&mut self, p: &Value) -> Result<Value, String> {
let path = s(p, "path").ok_or("missing `path`")?;
let st = self.session.active().ok_or("no document open")?;
let page = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize;
let page = page_index(p, st.doc.page_count())?;
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(2.0).clamp(0.05, 16.0);
let img = self
.renderer
@@ -263,3 +263,15 @@ impl Backend for Headless {
"headless (in-process engine, no window)".into()
}
}
/// A supplied page must be a representable unsigned index; only omission selects page zero.
fn page_index(p: &Value, count: usize) -> Result<usize, String> {
let page = match p.get("page") {
None => 0,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or("`page` must be a nonnegative integer representable as a page index")?,
};
if page >= count {
return Err(format!("no page {page} (the document has {count} pages, indices are 0-based)"));
}
Ok(page)
}
+84
View File
@@ -416,3 +416,87 @@ fn batch_steps_use_earlier_results() {
);
assert_eq!(v["completed"], 2);
}
#[test]
fn explicit_typing_honors_caret_range_and_undo_without_changing_shortcuts() {
let mut s = server();
assert_eq!(call(&mut s, "type_text", json!({"text":"X"}))["isError"], true);
let f = ok(&mut s, "execute", json!({"command":"frame.create","params":{"rect":[36,36,300,200],"content":"text","text":"Hello"}}));
let story = f["story"].as_u64().unwrap();
for (anchor, focus, expected) in [(5, 5, "HelloX"), (0, 5, "X")] {
ok(&mut s, "set_story_text", json!({"story":story,"text":"Hello"}));
ok(&mut s, "execute", json!({"command":"text.select","params":{"story":story,"anchor":anchor,"focus":focus}}));
ok(&mut s, "type_text", json!({"text":"X"}));
assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], expected);
ok(&mut s, "execute", json!({"command":"edit.undo"}));
assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], "Hello");
}
ok(&mut s, "execute", json!({"command":"text.placeCaret","params":{"frame":f["id"],"point":[100,50]}}));
ok(&mut s, "type_text", json!({"text":"X"}));
assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], "HelloX");
assert_eq!(ok(&mut s, "key", json!({"key":"T"}))["handledBy"], "tool.select");
assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], "HelloX");
}
#[test]
fn story_queries_distinguish_missing_from_empty() {
let mut s = server();
assert_eq!(call(&mut s, "get_story", json!({"story":999}))["isError"], true);
let f = ok(&mut s, "execute", json!({"command":"frame.create","params":{"rect":[36,36,300,200],"content":"text"}}));
assert_eq!(ok(&mut s, "get_story", json!({"story":f["story"]}))["text"], "");
assert_eq!(ok(&mut s, "get_story", json!({"frame":f["id"]}))["text"], "");
ok(&mut s, "execute", json!({"command":"text.select","params":{"story":f["story"],"anchor":0}}));
assert_eq!(ok(&mut s, "get_story", json!({}))["text"], "");
}
#[test]
fn page_contract_rejects_supplied_invalid_values_without_writes() {
let mut s = server();
ok(&mut s, "new_document", json!({"sample":true}));
let path = tmp("page-contract.png");
for tool in ["export_png", "render_page"] {
for page in
[json!(-1), json!(-2), json!(1.5), Value::Null, json!("0"), json!(true), json!(u64::MAX), json!(18446744073709551616_f64), json!(99)]
{
std::fs::write(&path, b"KEEP").unwrap();
let args = json!({"page":page,"path":path,"scale":0.1});
assert_eq!(call(&mut s, tool, args)["isError"], true, "{tool}: {page}");
assert_eq!(std::fs::read(&path).unwrap(), b"KEEP");
}
}
let first = image_of(&call(&mut s, "render_page", json!({"page":0,"scale":0.1})));
let next = image_of(&call(&mut s, "render_page", json!({"page":1,"scale":0.1})));
assert_ne!(first, next);
assert_eq!(first, image_of(&call(&mut s, "render_page", json!({"scale":0.1}))));
ok(&mut s, "export_png", json!({"page":1,"path":path,"scale":0.1}));
assert_eq!(std::fs::read(&path).unwrap(), next);
ok(&mut s, "export_png", json!({"path":path,"scale":0.1}));
assert_eq!(std::fs::read(&path).unwrap(), first);
for tool in ["render_page", "export_png"] {
let definition = tool_definitions().into_iter().find(|v| v["name"] == tool).unwrap();
assert_eq!(definition["inputSchema"]["properties"]["page"]["minimum"], 0);
}
let mut b = Headless::with_document();
for method in ["ui.render", "app.export"] {
for page in [json!(-1), Value::Null, json!("0"), json!(99)] {
std::fs::write(&path, b"KEEP").unwrap();
assert!(b.call(method, json!({"page":page,"path":path,"scale":0.1})).is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"KEEP");
}
std::fs::remove_file(&path).unwrap();
assert!(b.call(method, json!({"page":-1,"path":path,"scale":0.1})).is_err());
assert!(!path.exists());
}
}
#[test]
fn explicit_typing_rejects_stale_selection_and_closed_document() {
let mut b = Headless::with_document();
let f = b.session.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"Hello"})).unwrap();
b.session.execute("text.select", &json!({"story":f["story"],"anchor":5})).unwrap();
b.session.active_mut().unwrap().selection.text.as_mut().unwrap().story.0 = 999;
assert!(b.call("ui.text", json!({"text":"X"})).is_err());
assert_eq!(b.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello");
b.session.execute("file.close", &json!({})).unwrap();
assert!(b.call("ui.text", json!({"text":"X"})).is_err());
}
+21 -10
View File
@@ -106,7 +106,8 @@ pub fn tool_definitions() -> Vec<Value> {
"Run any DesignCraft command by id with JSON params (ids and params from list_commands). Examples: \
{\"command\":\"frame.create\",\"params\":{\"rect\":[36,36,576,300],\"content\":\"text\",\"text\":\"Hello\"}} → {id, story}; \
{\"command\":\"type.char\",\"params\":{\"size\":24}}; {\"command\":\"edit.undo\"}. Coordinates are points in spread space. \
In the desktop app UI-only commands (view.*, window.*, app.*) work too.",
In the desktop app the UI-only commands (view.*, window.*, help.*, edit.dynamicSpelling, and app.* except app.links, \
which works everywhere) work too.",
obj(json!({"command": string("Command id, e.g. frame.create"), "params": params_schema()}), &["command"]),
false,
),
@@ -137,16 +138,17 @@ pub fn tool_definitions() -> Vec<Value> {
"inspect_document",
"Inspect document",
"Summary of the active document: settings, pages (index, name, bounds, margins, columns), spreads with items (id, kind, \
bounds, fill, stroke, story), stories (id, frames, length, overset, preview), layers, paragraph/character styles, \
swatches, selection, active tool.",
bounds, fill, stroke, story), stories (id, frames, length in UTF-8 bytes, overset, preview), layers, \
paragraph/character styles, swatches, selection, active tool.",
empty(),
true,
),
tool(
"get_story",
"Get story",
"Full text of a story plus its frames, paragraph count, line count and overset position. Identify it by `story` id or \
by a text `frame` id (default: the selection).",
"Full text of a story plus its length, frames, paragraph count, line count and overset position (length and positions \
are UTF-8 byte offsets, the unit text.select takes). Identify it by `story` id or by a text `frame` id (default: the \
selection).",
obj(json!({"story": int("Story id"), "frame": int("Text frame id")}), &[]),
true,
),
@@ -223,7 +225,7 @@ pub fn tool_definitions() -> Vec<Value> {
also write it to `path`.",
obj(
json!({
"page": int("Page index, 0-based (default 0; in the desktop app default = the current page)"),
"page": {"type": "integer", "minimum": 0, "description": "Page index, 0-based (default 0; in the desktop app default = the current page)"},
"scale": num("Pixels per point (default 1 = 72 ppi)"),
"bleed": boolean("Include the bleed area"),
"path": string("Also write the PNG here"),
@@ -237,7 +239,7 @@ pub fn tool_definitions() -> Vec<Value> {
"Export PNG",
"Export one page as a PNG (or JPEG if the path ends in .jpg) file. Returns the path and pixel size.",
obj(
json!({"path": string("Destination file"), "page": int("Page index, 0-based (default 0)"), "scale": num("Pixels per point (default 2)")}),
json!({"path": string("Destination file"), "page": {"type": "integer", "minimum": 0, "description": "Page index, 0-based (default 0; in the desktop app default = the current page)"}, "scale": num("Pixels per point (default 2)")}),
&["path"],
),
false,
@@ -303,7 +305,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"type_text",
"Type text",
"Type text at the text insertion point (as the keyboard would with the type tool), or into the focused dialog field in \
"Type text at the text insertion point, independently of the active tool, or into the focused dialog field in \
the desktop app. Put a caret first with pointer (tool \"type\") or execute text.placeCaret / text.select.",
obj(json!({"text": string("Text to type (\\n = new paragraph)")}), &["text"]),
false,
@@ -409,6 +411,15 @@ fn pick(a: &Args, keys: &[&str]) -> Value {
Value::Object(keys.iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect())
}
/// Page omission selects a default, but an explicitly supplied null must reach validation.
fn page_params(a: &Args, keys: &[&str]) -> Value {
let mut p = pick(a, keys);
if let Some(page) = a.get("page") {
p["page"] = page.clone();
}
p
}
fn command_params(v: Option<&Value>) -> Result<Value, String> {
match v {
None | Some(Value::Null) => Ok(json!({})),
@@ -463,7 +474,7 @@ fn story_id(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
}
fn render_page(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
let r = b.call("ui.render", pick(a, &["page", "scale", "bleed"]))?;
let r = b.call("ui.render", page_params(a, &["page", "scale", "bleed"]))?;
let b64 = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?.to_string();
let path = a.get("path").and_then(Value::as_str);
if let Some(path) = path {
@@ -550,7 +561,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
"render_page" => render_page(b, a),
"export_png" => {
req_str(a, "path")?;
j(b.call("app.export", pick(a, &["path", "page", "scale"]))?)
j(b.call("app.export", page_params(a, &["path", "page", "scale"]))?)
}
"screenshot" => screenshot(b, a),
"select_tool" => j(b.call("ui.tool.select", json!({"tool": req_str(a, "tool")?}))?),
+17 -13
View File
@@ -154,6 +154,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: archival.is_some(),
interpolate: archival.is_none(),
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
@@ -327,6 +328,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: false,
interpolate: true,
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
@@ -421,6 +423,8 @@ pub(crate) struct Exporter<'a> {
pub reverse_cmaps: HashMap<u32, Arc<HashMap<u32, char>>>,
/// Convert CMYK to RGB (PDF/A: krilla needs a CMYK output profile we don't ship yet).
pub rgb_only: bool,
/// Ask viewers to smooth upscaled images (`/Interpolate`). Off for PDF/A, which forbids it.
interpolate: bool,
/// Tagged PDF: the structure in reading order (stories gather their frames' content).
tags: Vec<TagEntry>,
story_tags: HashMap<designcraft_doc::StoryId, Vec<Identifier>>,
@@ -1168,21 +1172,21 @@ impl Exporter<'_> {
let data = asset.data.clone();
let fmt = image::guess_format(&data).ok();
let img = match fmt {
Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), true).ok(),
Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), self.interpolate).ok(),
// A CMYK TIFF keeps its ink values (`image` decodes it to RGB, and 100% K would print
// as four-colour black). PDF/A exports are RGB only.
Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data) {
Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, self.interpolate) {
Some(img) => Some(img),
None => {
self.warn(format!(
"{}: converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)",
asset.name
));
lossless(&data, fmt)
lossless(&data, fmt, self.interpolate)
}
},
_ if self.opts.compress_images => recompress(&data).or_else(|| lossless(&data, fmt)),
_ => lossless(&data, fmt),
_ if self.opts.compress_images => recompress(&data, self.interpolate).or_else(|| lossless(&data, fmt, self.interpolate)),
_ => lossless(&data, fmt, self.interpolate),
};
if img.is_none() {
self.warn(format!("image `{}` could not be decoded and was skipped", asset.name));
@@ -1276,15 +1280,15 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option<Vec<u8>> {
Some(out)
}
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>) -> Option<Image> {
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, interpolate: bool) -> Option<Image> {
// krilla reads PNG, GIF and WebP lazily: a damaged file is accepted here and fails the whole
// export when the PDF is written. Decoding it first means it is skipped, like any other
// image that can't be decoded.
let rgba = designcraft_images::decode_rgba(data)?;
let direct = match fmt {
Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), true).ok(),
Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), true).ok(),
Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), true).ok(),
Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), interpolate).ok(),
Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), interpolate).ok(),
Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), interpolate).ok(),
_ => None,
};
direct.or_else(|| {
@@ -1323,18 +1327,18 @@ impl krilla::image::CustomImage for CmykImage {
}
/// A CMYK TIFF as a DeviceCMYK image. `None` when it can't be read as CMYK.
fn cmyk_tiff(data: &[u8]) -> Option<Image> {
fn cmyk_tiff(data: &[u8], interpolate: bool) -> Option<Image> {
let r = designcraft_images::decode_cmyk_tiff(data)?;
// krilla panics when the channel lengths don't match the size.
let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?;
if r.cmyk.len() != pixels.checked_mul(4)? || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) {
return None;
}
Image::from_custom(CmykImage(Arc::new(r)), true).ok()
Image::from_custom(CmykImage(Arc::new(r)), interpolate).ok()
}
/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded.
fn recompress(data: &[u8]) -> Option<Image> {
fn recompress(data: &[u8], interpolate: bool) -> Option<Image> {
let img = image::load_from_memory(data).ok()?;
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
return None;
@@ -1343,5 +1347,5 @@ fn recompress(data: &[u8]) -> Option<Image> {
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
Image::from_jpeg(buf.into(), true).ok()
Image::from_jpeg(buf.into(), interpolate).ok()
}
+49
View File
@@ -296,6 +296,55 @@ fn cmyk_tiff_keeps_its_inks() {
}
}
/// An 8×8 raster encoded as `fmt`; `alpha` gives it a transparent corner.
fn raster(fmt: image::ImageFormat, alpha: bool) -> Vec<u8> {
let img =
image::RgbaImage::from_fn(8, 8, |x, y| image::Rgba([(x * 30) as u8, (y * 30) as u8, 128, if alpha && x == 0 && y == 0 { 0 } else { 255 }]));
let img = if fmt == image::ImageFormat::Jpeg {
image::DynamicImage::ImageRgb8(image::DynamicImage::ImageRgba8(img).to_rgb8())
} else {
image::DynamicImage::ImageRgba8(img)
};
let mut b = std::io::Cursor::new(Vec::new());
img.write_to(&mut b, fmt).unwrap();
b.into_inner()
}
/// Each image XObject's /Interpolate value (`None` when absent).
fn interpolate_flags(bytes: &[u8]) -> Vec<Option<bool>> {
use hayro_syntax::object::Name;
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
pdf.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.filter(|s| s.dict().get::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.map(|s| s.dict().get::<bool>("Interpolate"))
.collect()
}
/// PDF/A-2b rejected placed PNG, JPEG, GIF and WebP images (#71): they were written with
/// /Interpolate true, which PDF/A forbids. Ordinary exports keep interpolation.
#[test]
fn archival_exports_keep_placed_images_without_interpolation() {
use image::ImageFormat as F;
let mut d = doc_with_text("x");
for (fmt, alpha) in [(F::Png, true), (F::Png, false), (F::Jpeg, false), (F::Gif, true), (F::WebP, true)] {
place(&mut d, raster(fmt, alpha), (8, 8));
}
for compress_images in [false, true] {
let opts = PdfOptions { compress_images, created: Some(1_700_000_000), ..Default::default() };
let a = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, ..opts.clone() });
let a = a.unwrap_or_else(|e| panic!("PDF/A-2b with images (compress {compress_images}): {e:?}"));
let flags = interpolate_flags(&a.bytes);
assert!(flags.len() >= 5, "every placed image is in the PDF/A file: {flags:?}");
assert!(flags.iter().all(|f| *f != Some(true)), "{flags:?}");
let plain = export_pdf(&d, &Cache::new(), &opts).unwrap();
let flags = interpolate_flags(&plain);
assert!(flags.len() >= 5, "{flags:?}");
assert!(flags.contains(&Some(true)), "ordinary exports interpolate: {flags:?}");
}
}
/// What a page draws: each glyph (its text and outline bounds) and each filled path's bounds.
fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec<kurbo::Rect>) {
use hayro_interpret::font::Glyph;
+22 -1
View File
@@ -276,7 +276,9 @@ impl Ctx {
for e in p.els() {
match e.name.as_str() {
"r" => self.run(e, st, allow_notes),
"hyperlink" | "smartTag" | "ins" | "fldSimple" | "customXml" => self.inline(e, st, allow_notes),
// Tracked changes import as accepted: insertions and move destinations are kept,
// deletions (`del`) and move sources (`moveFrom`) are dropped.
"hyperlink" | "smartTag" | "ins" | "moveTo" | "fldSimple" | "customXml" => self.inline(e, st, allow_notes),
_ => {}
}
}
@@ -469,4 +471,23 @@ mod tests {
assert_eq!(i.para_styles[0].para.space_before, Some(12.0));
assert_eq!(i.char_styles[0].chars.font_style.as_deref(), Some("Italic"));
}
#[test]
fn tracked_changes_import_as_accepted_including_moves() {
// Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import.
let document = format!(
r#"<w:document {W}><w:body>
<w:p><w:r><w:t xml:space="preserve">Kept text. </w:t></w:r>
<w:moveTo w:id="1" w:author="A"><w:r><w:t xml:space="preserve">MOVED HERE.</w:t></w:r>
<w:del w:id="5" w:author="A"><w:r><w:delText>MOVED THEN DELETED</w:delText></w:r></w:del></w:moveTo>
<w:r><w:t xml:space="preserve"> Plain </w:t></w:r>
<w:ins w:id="2" w:author="A"><w:r><w:t>INSERTED</w:t></w:r></w:ins>
<w:del w:id="3" w:author="A"><w:r><w:delText>DELETED</w:delText></w:r></w:del></w:p>
<w:p><w:moveFrom w:id="4" w:author="A"><w:r><w:t>MOVED HERE.</w:t></w:r></w:moveFrom><w:r><w:t>End.</w:t></w:r></w:p>
</w:body></w:document>"#
);
let i = import(&docx(&document, "", "")).unwrap();
i.story.check().unwrap();
assert_eq!(i.story.text, "Kept text. MOVED HERE. Plain INSERTED\nEnd.");
}
}
+27
View File
@@ -28,6 +28,33 @@ pub fn xlsx_records(bytes: &[u8], sheet: Option<&str>) -> Result<Vec<Vec<String>
use designcraft_doc::{CharAttrs, ParaAttrs, Story};
/// Joins UTF-16 code units that arrive one at a time (RTF `\uN`, Tagged Text `<0xNNNN>`) into
/// characters. A character past U+FFFF comes as two units, a surrogate pair: the first half
/// waits for the second.
#[derive(Default)]
pub(crate) struct Utf16Units {
high: Option<u32>,
}
impl Utf16Units {
/// The character that `unit` is or completes. Half a pair without its partner is no character.
pub(crate) fn push(&mut self, unit: u32) -> Option<char> {
match (self.high.take(), unit) {
(Some(high), 0xDC00..=0xDFFF) => char::from_u32(0x10000 + ((high - 0xD800) << 10) + (unit - 0xDC00)),
(_, 0xD800..=0xDBFF) => {
self.high = Some(unit);
None
}
_ => char::from_u32(unit),
}
}
/// Other text came in between: a waiting first half has no partner.
pub(crate) fn reset(&mut self) {
self.high = None;
}
}
/// A paragraph or character style found in the source.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ImportedStyle {
+20 -2
View File
@@ -70,6 +70,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
let mut st = Story::new(StoryId(0));
let mut stack: Vec<State> = vec![State { uc: 1, ..Default::default() }];
let mut pending = 0usize; // characters still to skip after \uN
let mut units = crate::Utf16Units::default();
let mut buf = String::new();
let mut fmt = stack[0].format();
let mut warnings = Vec::new();
@@ -124,6 +125,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
if let Ok(b) = u8::from_str_radix(&hex, 16)
&& !stack.last().is_some_and(|s| s.skip)
{
units.reset();
buf.push(cp1252(b));
}
continue;
@@ -225,8 +227,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
}
"u" => {
if let Some(n) = param {
let cp = if n < 0 { (n + 65536) as u32 } else { n as u32 };
if let Some(ch) = char::from_u32(cp) {
let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 };
if let Some(ch) = units.push(unit) {
buf.push(ch);
}
pending = stack.last().map_or(1, |s| s.uc);
@@ -243,6 +245,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
continue;
}
if !stack.last().is_some_and(|s| s.skip) {
units.reset();
buf.push(c);
}
}
@@ -287,4 +290,19 @@ Caf\'e9 \u8364? price\tab tab\line next\par
assert_eq!(st.format_after(0).over.size, Some(12.0));
assert!(!st.text.contains("Times"), "font table skipped");
}
/// Characters past U+FFFF are written as two `\uN` words, a UTF-16 surrogate pair (the form
/// our own exporter and Word write). Each half was converted alone and dropped.
#[test]
fn surrogate_pairs_become_one_character() {
let i = import(br"{\rtf1\ansi\uc1 A\u-10179?\u-8704?B \u-10174?\u-8265?\u30000?}").unwrap();
assert_eq!(i.story.text, "A\u{1F600}B \u{20BB7}\u{7530}");
// What the exporter writes comes back.
let mut st = Story::new(StoryId(1));
st.insert(0, "x \u{1F600} \u{20BB7}\u{7530} y");
let doc = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default());
assert_eq!(import(crate::export::rtf(&doc, &st).as_bytes()).unwrap().story.text, st.text);
// Half a pair is not a character: it is still left out.
assert_eq!(import(br"{\rtf1 A\u-10179?B\u-8704?C}").unwrap().story.text, "ABC");
}
}
+18 -1
View File
@@ -110,12 +110,14 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
_ => runs.push((c.to_string(), fmt.clone())),
}
};
let mut units = crate::Utf16Units::default();
let mut i = 0;
let mut skipped = 0;
while i < chars.len() {
let c = chars[i];
match c {
'\\' if i + 1 < chars.len() => {
units.reset();
push(&mut paras, chars[i + 1], &fmt);
i += 2;
}
@@ -143,7 +145,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
match name {
n if n.starts_with("0x") => {
if let Ok(u) = u32::from_str_radix(&n[2..], 16)
&& let Some(ch) = char::from_u32(u)
&& let Some(ch) = units.push(u)
{
push(&mut paras, ch, &fmt);
}
@@ -193,6 +195,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
i += 1;
}
c => {
units.reset();
push(&mut paras, c, &fmt);
i += 1;
}
@@ -226,6 +229,20 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
mod tests {
use super::*;
/// Characters past U+FFFF are written as two tags, a UTF-16 surrogate pair. Each half was
/// converted alone and dropped.
#[test]
fn surrogate_pairs_become_one_character() {
let i = import("<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Body>A<0xD83D><0xDE00>B <0xD842><0xDFB7><0x7530>".as_bytes()).unwrap();
assert_eq!(i.story.text, "A\u{1F600}B \u{20BB7}\u{7530}");
// What the exporter writes comes back.
let doc = Document::new(&designcraft_doc::build::NewDocument::default());
let back = import(tagged_text(&doc, &i.story).as_bytes()).unwrap();
assert_eq!(back.story.text, i.story.text);
// Half a pair is not a character: it is still left out.
assert_eq!(import("<ASCII-WIN>\r\n<ParaStyle:Body>A<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC");
}
#[test]
fn tagged_text_round_trips() {
let src = "<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Head>Caf<0x00E9> \\<1\\>\r\n<ParaStyle:Body>A <CharStyle:Em>word<CharStyle:> and <cSize:18.000000>big<cSize:> text";
+1 -1
View File
@@ -20,7 +20,7 @@ const MARK_PATH: &str = "M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,7
/// The other ArtCraft apps (app-page slug, name, what it is) — same list as the README.
pub const SIBLINGS: &[(&str, &str, &str)] = &[
("photocraft", "PhotoCraft", "image editing"),
("drawcraft", "VectorCraft", "vector illustration"),
("vectorcraft", "VectorCraft", "vector illustration"),
("filmcraft", "FilmCraft", "video editing, color and sound"),
("lightcraft", "LightCraft", "photo library and raw development"),
("pdfcraft", "PdfCraft", "reading, organizing and protecting PDFs"),
+112 -4
View File
@@ -318,6 +318,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
draw_tool_overlays(app, &painter, &xf);
if rulers {
draw_rulers(app, ui, full, rect, &xf, &doc, &layout, &t);
ruler_units_menus(app, &resp, full, rect, &doc);
}
if let Some(r) = sel_rect
&& !preview
@@ -1247,8 +1248,7 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) {
#[allow(clippy::too_many_arguments)]
fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) {
let painter = ui.painter_at(full);
let top = Rect::from_min_max(pos2(rect.min.x, full.min.y), pos2(full.max.x, rect.min.y));
let left = Rect::from_min_max(pos2(full.min.x, rect.min.y), pos2(rect.min.x, full.max.y));
let (top, left) = ruler_rects(full, rect);
painter.rect_filled(top, 0.0, t.ruler);
painter.rect_filled(left, 0.0, t.ruler);
painter.rect_filled(Rect::from_min_max(full.min, rect.min), 0.0, t.ruler);
@@ -1262,11 +1262,11 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf,
let Some(i) = current_slot(app, layout) else { return };
let slot = &layout.slots[i];
let origin = Point::new(slot.bounds.x0, slot.bounds.y0);
let unit = doc.settings.horizontal_units;
let (major, sub) = unit.ruler_ticks(xf.zoom);
let font = egui::FontId::proportional(9.0);
let tick = Stroke::new(1.0, t.ruler_tick);
// Horizontal.
let unit = doc.settings.horizontal_units;
let (major, sub) = unit.ruler_ticks(xf.zoom);
let c0 = xf.to_canvas(top.min).x - origin.x;
let c1 = xf.to_canvas(pos2(top.max.x, 0.0)).x - origin.x;
let mut k = (c0 / major).floor() as i64;
@@ -1291,6 +1291,8 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf,
k += 1;
}
// Vertical.
let unit = doc.settings.vertical_units;
let (major, sub) = unit.ruler_ticks(xf.zoom);
let c0 = xf.to_canvas(pos2(0.0, left.min.y)).y - origin.y;
let c1 = xf.to_canvas(pos2(0.0, left.max.y)).y - origin.y;
let mut k = (c0 / major).floor() as i64;
@@ -1325,6 +1327,43 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf,
}
}
/// The horizontal (top) and vertical (left) rulers around the view `rect` in `full`.
fn ruler_rects(full: Rect, rect: Rect) -> (Rect, Rect) {
(
Rect::from_min_max(pos2(rect.min.x, full.min.y), pos2(full.max.x, rect.min.y)),
Rect::from_min_max(pos2(full.min.x, rect.min.y), pos2(rect.min.x, full.max.y)),
)
}
/// Right-clicking a ruler opens a menu of units for that ruler.
fn ruler_units_menus(app: &mut DesignApp, resp: &egui::Response, full: Rect, rect: Rect, doc: &Document) {
let (top, left) = ruler_rects(full, rect);
let at = resp.interact_pointer_pos();
for (ruler, key, cur) in [(top, "horizontalUnits", doc.settings.horizontal_units), (left, "verticalUnits", doc.settings.vertical_units)] {
let open = if resp.secondary_clicked() {
Some(egui::SetOpenCommand::Bool(at.is_some_and(|p| ruler.contains(p))))
} else if resp.clicked() {
Some(egui::SetOpenCommand::Bool(false))
} else {
None
};
egui::Popup::context_menu(resp).id(resp.id.with(("ruler_units", key))).open_memory(open).show(|ui| ruler_units_menu(app, ui, key, cur));
}
}
/// Every unit, the current one checked; picking one sets `key` (`horizontalUnits` or
/// `verticalUnits`) through `document.preferences`.
fn ruler_units_menu(app: &mut DesignApp, ui: &mut egui::Ui, key: &str, cur: designcraft_geom::Unit) {
for u in designcraft_geom::Unit::ALL {
let label = crate::i18n::tr(&app.ui.language, u.label());
let text = if u == cur { format!("✓ {label}") } else { format!(" {label}") };
if ui.button(crate::rtl::widget(ui, text)).clicked() {
let _ = app.run("document.preferences", json!({ key: u }));
ui.close();
}
}
}
fn fmt_tick(v: f64) -> String {
let r = v.round();
if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") }
@@ -1726,3 +1765,72 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec
}
true
}
#[cfg(test)]
mod tests {
use designcraft_geom::Unit;
use egui_kittest::{Harness, kittest::Queryable};
use super::*;
fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.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
}
fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) {
h.hover_at(p);
h.step();
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
}
h.run_steps(3);
}
fn units(h: &Harness<'_, DesignApp>) -> (Unit, Unit) {
let s = &h.state().session.doc().unwrap().doc.settings;
(s.horizontal_units, s.vertical_units)
}
#[test]
fn right_clicking_a_ruler_sets_that_rulers_units() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let mut h = harness(app);
let rect = h.state().canvas_rect.unwrap();
assert_eq!(units(&h), (Unit::Picas, Unit::Picas));
// Vertical ruler: the current unit is checked; picking one sets only the vertical units.
right_click(&mut h, pos2(rect.min.x - RULER / 2.0, rect.center().y));
h.get_by_label("✓ Picas");
h.get_by_label(" Millimeters").click();
h.run_steps(3);
assert_eq!(units(&h), (Unit::Picas, Unit::Millimeters));
// Horizontal ruler.
right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0));
h.get_by_label("✓ Picas");
h.get_by_label(" Inches").click();
h.run_steps(3);
assert_eq!(units(&h), (Unit::Inches, Unit::Millimeters));
// The page and the ruler corner open no units menu.
for p in [rect.center(), rect.min - vec2(RULER / 2.0, RULER / 2.0)] {
right_click(&mut h, p);
assert!(h.query_by_label(" Points").is_none());
}
}
#[test]
fn rulers_open_no_units_menu_without_a_document() {
let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()));
let Some(rect) = h.state().canvas_rect else { return };
right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0));
assert!(h.query_by_label(" Points").is_none());
}
}
+187 -81
View File
@@ -21,86 +21,105 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
let full = ui.max_rect();
let mut menus_end = full.min.x;
let mut tools_start = full.max.x;
ui.horizontal_centered(|ui| {
if icons::button(ui, "home", 24.0, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() {
app.session_home();
}
ui.add_space(6.0);
if !app.native_menu {
ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
ui.style_mut().spacing.button_padding = vec2(7.0, 3.0);
crate::menus::menu_bar(app, ui);
}
menus_end = ui.min_rect().max.x;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(4.0);
// Search field.
let (r, _) = ui.allocate_exact_size(vec2(125.0, 18.0), Sense::click());
ui.painter().rect(r, 1.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon);
crate::rtl::paint(
ui.painter(),
r.min + vec2(20.0, 9.0),
egui::Align2::LEFT_CENTER,
crate::i18n::tr(&app.ui.language, "Search"),
egui::FontId::proportional(11.0),
t.text_dim,
);
ui.add_space(8.0);
let current = app.ui.workspace.clone();
let shown = crate::i18n::workspace_name(&app.ui.language, &current);
ui.menu_button(crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| {
let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect();
for w in
["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"]
.into_iter()
.map(str::to_string)
.chain(customs.iter().cloned())
{
if ui.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))).clicked()
// The right-hand controls' width, measured last frame: in a window too narrow for them
// and the menus they follow the menus, and the bar scrolls sideways.
let tools_w_id = ui.id().with("tools_width");
let tools_w: f32 = ui.data(|d| d.get_temp(tools_w_id)).unwrap_or(0.0);
crate::widgets::overflow_scrolling(ui);
egui::ScrollArea::horizontal().id_salt("app_bar_scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_centered(|ui| {
if icons::button(ui, "home", 24.0, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() {
app.session_home();
}
ui.add_space(6.0);
if !app.native_menu {
ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
ui.style_mut().spacing.button_padding = vec2(7.0, 3.0);
crate::menus::menu_bar(app, ui);
}
menus_end = ui.min_rect().max.x;
let size = vec2(ui.available_width().max(tools_w), ui.available_height());
let tools = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(4.0);
// Search field.
let (r, _) = ui.allocate_exact_size(vec2(125.0, 18.0), Sense::click());
ui.painter().rect(r, 1.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon);
crate::rtl::paint(
ui.painter(),
r.min + vec2(20.0, 9.0),
egui::Align2::LEFT_CENTER,
crate::i18n::tr(&app.ui.language, "Search"),
egui::FontId::proportional(11.0),
t.text_dim,
);
ui.add_space(8.0);
let current = app.ui.workspace.clone();
let shown = crate::i18n::workspace_name(&app.ui.language, &current);
ui.menu_button(crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| {
let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect();
for w in
["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"]
.into_iter()
.map(str::to_string)
.chain(customs.iter().cloned())
{
let _ = app.run("window.workspace", json!({"name": w}));
if ui
.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w)))
.clicked()
{
let _ = app.run("window.workspace", json!({"name": w}));
ui.close();
}
}
ui.separator();
if ui
.button(crate::rtl::widget(
ui,
format!(
"{} {}",
crate::i18n::tr(&app.ui.language, "Reset"),
crate::i18n::workspace_name(&app.ui.language, &current)
),
))
.clicked()
{
let _ = app.run("window.resetWorkspace", json!({}));
ui.close();
}
}
ui.separator();
if ui
.button(crate::rtl::widget(
ui,
format!("{} {}", crate::i18n::tr(&app.ui.language, "Reset"), crate::i18n::workspace_name(&app.ui.language, &current)),
))
.clicked()
{
let _ = app.run("window.resetWorkspace", json!({}));
ui.close();
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() {
let _ = app.run("window.newWorkspace", json!({}));
ui.close();
}
if !customs.is_empty() {
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| {
for w in &customs {
if ui.button(w).clicked() {
let _ = app.run("window.deleteWorkspace", json!({"name": w}));
ui.close();
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() {
let _ = app.run("window.newWorkspace", json!({}));
ui.close();
}
if !customs.is_empty() {
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| {
for w in &customs {
if ui.button(w).clicked() {
let _ = app.run("window.deleteWorkspace", json!({"name": w}));
ui.close();
}
}
}
});
});
}
});
ui.add_space(6.0);
if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() {
app.status("Export a PDF, IDML or package to share — no cloud account needed.");
}
ui.add_space(8.0);
// Always one click away: the ArtCraft community Discord.
if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) {
let _ = app.run("help.discord", json!({}));
}
tools_start = ui.min_rect().min.x;
ui.min_rect().width()
});
ui.add_space(6.0);
if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() {
app.status("Export a PDF, IDML or package to share — no cloud account needed.");
if (tools.inner - tools_w).abs() > 0.5 {
ui.data_mut(|d| d.insert_temp(tools_w_id, tools.inner));
ui.ctx().request_discard("app bar: the right-hand controls changed width");
}
ui.add_space(8.0);
// Always one click away: the ArtCraft community Discord.
if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) {
let _ = app.run("help.discord", json!({}));
}
tools_start = ui.min_rect().min.x;
});
});
// Centred title.
@@ -142,14 +161,18 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 8, right: 8, top: 5, bottom: 3 }).stroke(Stroke::new(1.0, t.divider)),
)
.show(ui, |ui| {
ui.horizontal(|ui| {
let text_mode =
matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some());
if text_mode {
control_text(app, ui);
} else {
control_object(app, ui);
}
// Wider than the window: the bar scrolls sideways.
crate::widgets::overflow_scrolling(ui);
egui::ScrollArea::horizontal().id_salt("control_bar_scroll").auto_shrink([false, true]).show(ui, |ui| {
ui.horizontal(|ui| {
let text_mode =
matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some());
if text_mode {
control_text(app, ui);
} else {
control_object(app, ui);
}
});
});
});
}
@@ -716,3 +739,86 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
});
});
}
#[cfg(test)]
mod tests {
use egui::{pos2, vec2};
use egui_kittest::kittest::Queryable;
use crate::test_window::{self, wheel};
/// The Control panel's last control with nothing selected.
const LAST_CONTROL: &str = "Jump to Next Column";
const FIRST_CONTROL: &str = "Rotate 90° Counterclockwise";
fn app() -> crate::DesignApp {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.control_bar = true;
app
}
fn rect(h: &egui_kittest::Harness<'static, test_window::Window>, label: &str) -> egui::Rect {
h.get_by_label(label).rect()
}
#[test]
fn a_narrow_window_scrolls_the_control_bar() {
let w = 700.0;
let mut h = test_window::open(app(), vec2(w, 600.0));
let bar = test_window::panel_rect(&h, "control_bar");
assert!(rect(&h, LAST_CONTROL).max.x > w, "the controls are wider than the window");
// A plain mouse wheel scrolls the bar sideways.
let first = rect(&h, FIRST_CONTROL).center();
wheel(&mut h, first, vec2(0.0, -3000.0));
let last = rect(&h, LAST_CONTROL);
let window = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(w, 600.0));
assert!(window.contains_rect(last) && bar.contains_rect(last), "{last:?} outside the bar {bar:?}");
assert_eq!(test_window::panel_rect(&h, "control_bar").height(), bar.height(), "the bar keeps its height");
// A popup opened from a scrolled control opens at that control.
let fill = h.get_by_label("Fill").rect();
let chip = pos2(fill.max.x + 15.0, fill.center().y);
h.hover_at(chip);
h.drag_at(chip);
h.drop_at(chip);
h.run_steps(4);
let item = rect(&h, "[Black]");
assert!(item.min.y > fill.max.y && (item.min.x - chip.x).abs() < 120.0, "popup item at {item:?}, control at {chip:?}");
}
#[test]
fn a_narrow_window_scrolls_the_app_bar() {
let w = 640.0;
let mut h = test_window::open(app(), vec2(w, 600.0));
let (help, share) = (rect(&h, "Help"), rect(&h, "Share"));
assert!(help.max.x <= share.min.x, "the menus ({help:?}) and the workspace controls ({share:?}) overlap");
wheel(&mut h, help.center(), vec2(0.0, -3000.0));
let share = rect(&h, "Share");
let window = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(w, 600.0));
assert!(window.contains_rect(share), "{share:?} outside the window");
}
#[test]
fn a_wide_window_does_not_scroll_the_bars() {
let mut h = test_window::open(app(), vec2(1400.0, 900.0));
let before = [rect(&h, FIRST_CONTROL), rect(&h, LAST_CONTROL), rect(&h, "Help"), rect(&h, "Share")];
assert!(before.iter().all(|r| r.max.x <= 1400.0), "{before:?}");
wheel(&mut h, before[0].center(), vec2(0.0, -3000.0));
wheel(&mut h, before[2].center(), vec2(0.0, -3000.0));
let after = [rect(&h, FIRST_CONTROL), rect(&h, LAST_CONTROL), rect(&h, "Help"), rect(&h, "Share")];
assert_eq!(after, before);
}
#[test]
fn a_scrolled_bar_keeps_its_captions_on_one_line() {
let mut app = app();
app.run("file.new", serde_json::json!({})).unwrap();
let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap();
app.run("selection.set", serde_json::json!({"ids": [f["id"]]})).unwrap();
let mut h = test_window::open(app, vec2(700.0, 600.0));
let bar = test_window::panel_rect(&h, "control_bar").center();
wheel(&mut h, bar, vec2(0.0, -3000.0));
// The text frame's last group: its caption gets only what is left of the row.
let columns = h.get_by_label("Columns").rect();
assert!(columns.height() < 20.0 && columns.width() > columns.height(), "Columns wraps: {columns:?}");
}
}
+119 -6
View File
@@ -8,7 +8,8 @@
//! - `ui.tool.select {tool}`, `ui.tool.list`
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`:
//! drive the active tool through the same path as the mouse
//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input
//! - `ui.key {key, shift?, alt?, cmd?}`: synthetic keyboard input
//! - `ui.text {text}`: insert at the explicit caret, or type into a focused UI field
//! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`:
//! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs)
//! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}`
@@ -216,9 +217,19 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
ok(Value::Null)
}
"ui.text" => {
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
ctx.request_repaint();
ok(Value::Null)
let Some(text) = s("text") else { return err("missing `text`") };
// 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()));
ctx.request_repaint();
ok(Value::Null)
} else {
if app.session.active().is_none_or(|st| st.selection.text.is_none_or(|t| st.doc.text_story(t.story, t.cell).is_none())) {
return err("no valid text insertion point: use text.select or text.placeCaret first");
}
wrap(app.run("text.insert", json!({"text": text})))
}
}
"ui.set" => {
let mut r = Ok(Value::Null);
@@ -313,7 +324,10 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
}
"ui.render" => {
let Some(st) = app.session.active() else { return err("no document") };
let page = p.get("page").and_then(Value::as_u64).map(|v| v as usize).or_else(|| crate::canvas::current_page(app)).unwrap_or(0);
let page = match page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count()) {
Ok(page) => page,
Err(e) => return err(e),
};
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
let Some(img) = app.canvas.renderer.render_page(
&st.doc,
@@ -336,7 +350,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) ->
}
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
"app.export" => wrap(app.run("app.exportPng", json!({"path": s("path"), "page": p.get("page"), "scale": p.get("scale")}))),
"app.export" => wrap(app.run("app.exportPng", p.clone())),
"app.quit" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
@@ -361,3 +375,102 @@ pub fn save_screenshot(app: &mut DesignApp, image: &egui::ColorImage, path: Opti
None => json!({"ok": false, "error": "no writer configured"}),
}
}
/// Parse explicit page indices without treating invalid values as omission.
pub(crate) fn page_index(p: &Value, default: usize, count: usize) -> Result<usize, String> {
let page = match p.get("page") {
None => default,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or("`page` must be a nonnegative integer representable as a page index")?,
};
if page >= count {
return Err(format!("no page {page} (the document has {count} pages, indices are 0-based)"));
}
Ok(page)
}
#[cfg(test)]
mod automation_contract_tests {
use super::*;
fn app() -> DesignApp {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages":2})).unwrap();
app
}
fn call(app: &mut DesignApp, ctx: &egui::Context, method: &str, params: Value) -> Value {
let (req, _) = ControlRequest::new(method, params);
match handle(app, ctx, &req) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => panic!("unexpected screenshot"),
}
}
fn frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec<egui::Event>) {
let mut output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
},
|ui| {
app.logic(ui.ctx());
app.ui(ui);
},
);
output.textures_delta.clear();
}
#[test]
fn control_typing_uses_explicit_caret_but_retains_widget_events() {
let mut app = app();
let ctx = egui::Context::default();
assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"X"}))["ok"], false);
let f = app.session.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"Hello"})).unwrap();
app.session.execute("text.select", &json!({"story":f["story"],"anchor":5})).unwrap();
assert_eq!(app.session.tool_id(), "selection");
assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"X"}))["ok"], true);
assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "HelloX");
app.session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello");
app.select_tool("type");
assert!(app.session.wants_text(), "the document would receive ordinary typing without widget focus");
app.ui.palette = Some(String::new());
for _ in 0..3 {
frame(&mut app, &ctx, vec![]);
}
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);
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");
}
#[test]
fn control_pages_validate_before_writer_and_preserve_omission() {
let mut app = app();
let ctx = egui::Context::default();
let writes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = writes.clone();
app.services.write = Some(Box::new(move |_, bytes| {
sink.lock().unwrap().push(bytes.to_vec());
Ok(())
}));
app.session.execute("frame.create", &json!({"rect":[36,36,300,200]})).unwrap();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)));
crate::canvas::go_to_page(&mut app, 1);
assert_eq!(crate::canvas::current_page(&app), Some(1));
for method in ["ui.render", "app.export"] {
for page in
[json!(-1), json!(-2), json!(1.5), Value::Null, json!("0"), json!(true), json!(u64::MAX), json!(18446744073709551616_f64), json!(99)]
{
assert_eq!(call(&mut app, &ctx, method, json!({"page":page,"path":"unchanged.png","scale":0.1}))["ok"], false, "{method}: {page}");
assert!(writes.lock().unwrap().is_empty());
}
assert_eq!(call(&mut app, &ctx, method, json!({"path":"omitted.png","scale":0.1}))["ok"], true);
assert_eq!(call(&mut app, &ctx, method, json!({"page":1,"path":"explicit.png","scale":0.1}))["ok"], true);
let mut bytes = writes.lock().unwrap();
assert_eq!(bytes.len(), 2);
assert_eq!(bytes[0], bytes[1]);
bytes.clear();
}
assert!(app.run("app.exportPng", json!({"page":Value::Null,"path":"unchanged.png"})).is_err());
assert!(writes.lock().unwrap().is_empty());
}
}
+116 -9
View File
@@ -144,6 +144,11 @@ fn user_dictionary(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
});
}
fn is_modifier_key(key: egui::Key) -> bool {
use egui::Key::*;
matches!(key, ShiftLeft | ShiftRight | ControlLeft | ControlRight | AltLeft | AltRight | SuperLeft | SuperRight)
}
fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let mut q = d.s("query");
ui.add(
@@ -153,11 +158,12 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
);
d.fields.insert("query".into(), json!(q));
let recording = d.s("recording");
// Capture the next key press for the command being recorded.
// Capture the next key press for the command being recorded. Pressing a modifier
// reports the modifier itself as a key; skip it and wait for the key it modifies.
if !recording.is_empty() {
let pressed = ui.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
egui::Event::Key { key, pressed: true, modifiers, .. } if !is_modifier_key(*key) => Some((*key, *modifiers)),
_ => None,
})
});
@@ -803,7 +809,9 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
let mut result: Option<bool> = None;
let alert_title = d.s("title");
let title = match d.id.as_str() {
"alert" => crate::i18n::tr(&app.ui.language, &alert_title),
"ruby" => crate::i18n::tr(&app.ui.language, "Ruby"),
"newDocument" => crate::i18n::tr(&app.ui.language, "New Document"),
"frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"),
@@ -837,7 +845,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
};
egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| {
ui.set_min_width(380.0);
ui.set_max_width(if d.id == "newDocument" && crate::i18n::is_rtl(&app.ui.language) { 380.0 } else { 640.0 });
ui.set_max_width(match d.id.as_str() {
"alert" => 440.0,
"newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0,
_ => 640.0,
});
if crate::i18n::is_rtl(&app.ui.language) {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -1366,6 +1378,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
});
}
"documentSetup" => document_setup(app, ui, &mut d),
"alert" => {
let file = d.s("file");
if !file.is_empty() {
crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0)));
ui.add_space(4.0);
}
crate::rtl::label(ui, d.s("message"));
}
_ => {}
}
ui.add_space(12.0);
@@ -1381,7 +1401,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
{
result = Some(true);
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
// An alert only has OK.
let cancel = d.id != "alert" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked();
if cancel || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
result = Some(false);
}
});
@@ -1498,7 +1520,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
"pdfImport" => {
let crop = d.s("crop");
app.run("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
app.open_file("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }}))
}
"print" => {
let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) };
@@ -1601,7 +1623,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
app.run("document.preferences", doc)
}
"newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})),
"importOptions" => app.run(
"importOptions" => app.open_file(
"file.place",
json!({"path": d.s("path"), "removeStyles": d.b("removeStyles"), "styleConflicts": d.s("styleConflicts"), "styleMap": d.fields.get("map").cloned().unwrap_or(json!({}))}),
),
@@ -1669,6 +1691,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
app.run("footnote.options", p)
}
"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")})),
id if id.starts_with("cmd:") => {
@@ -1683,10 +1706,12 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
let parsed = serde_json::from_str::<Value>(v).ok().filter(|x| !x.is_string());
p.insert(f.key, parsed.unwrap_or_else(|| json!(v)));
}
Some(Value::Bool(b)) => {
p.insert(f.key, json!(b));
Some(Value::String(_)) | None => {}
Some(value) => {
// Control/MCP callers can supply JSON directly. Preserve its type,
// including explicit null, for the command to interpret.
p.insert(f.key, value.clone());
}
_ => {}
}
}
let r = app.run(cid, Value::Object(p));
@@ -2654,6 +2679,73 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
mod tests {
use super::*;
fn command_app(id: &str, fields: Value) -> DesignApp {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.dialog = Some(Dialog::new(&format!("cmd:{id}"), fields));
app
}
#[test]
fn generic_new_document_preserves_typed_values_and_text_entry() {
for pages in [json!(2), json!(" 2 ")] {
let mut app = command_app(
"file.new",
json!({
"preset": "A4", "pages": pages, "facingPages": false, "gutter": 0,
"margins": {"top": 10, "bottom": 20, "inside": 30, "outside": 40},
"title": " ", "status": "internal", "primaryTextFrame": true
}),
);
confirm(&mut app).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.page_count(), 2);
assert!(!doc.settings.facing_pages);
assert!(doc.title.starts_with("Untitled-"));
for page in doc.spreads.iter().flat_map(|s| &s.pages) {
assert!((page.width - 595.2755905511812).abs() < 0.01);
assert!((page.height - 841.8897637795277).abs() < 0.01);
assert_eq!(page.columns.gutter, 0.0);
assert_eq!(page.margins.top, 10.0);
assert_eq!(page.margins.outside, 40.0);
}
assert!(doc.spreads.iter().all(|s| s.items.is_empty()), "undocumented fields stay excluded");
assert!(app.ui.dialog.is_none());
}
}
#[test]
fn generic_command_preserves_array_geometry() {
let mut app = command_app("file.new", json!({}));
confirm(&mut app).unwrap();
app.ui.dialog = Some(Dialog::new("cmd:frame.create", json!({"rect": [10, 20, 110, 220]})));
confirm(&mut app).unwrap();
let doc = &app.session.active().unwrap().doc;
let item = doc.spreads.iter().flat_map(|s| &s.items).next().unwrap();
assert_eq!(item.bounds(), designcraft_geom::Rect::new(10.0, 20.0, 110.0, 220.0));
}
#[test]
fn generic_explicit_null_uses_the_commands_existing_semantics() {
// file.new accepts null width by using the selected preset's width.
for width in [Value::Null, json!("null")] {
let mut app = command_app("file.new", json!({"preset": "A4", "width": width, "title": " Sample layout "}));
confirm(&mut app).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.title, "Sample layout");
assert!((doc.settings.page_width - 595.2755905511812).abs() < 0.01);
}
}
#[test]
fn generic_invalid_number_retains_dialog_without_creating_document() {
let mut app = command_app("file.new", json!({"width": 0}));
assert!(confirm(&mut app).is_err());
assert!(app.session.active().is_none());
let dialog = app.ui.dialog.as_ref().unwrap();
assert_eq!(dialog.fields["width"], 0);
assert!(dialog.fields["status"].as_str().unwrap().contains("page size out of range"));
}
#[test]
fn parses_command_params() {
let f = command_fields("{name, type: custom|lastPageNumber|chapterNumber, text?, rule?: {on, weight}, flag?: bool} — creates");
@@ -2681,4 +2773,19 @@ mod tests {
let _ = command_fields(c.params);
}
}
#[test]
fn shortcut_recorder_waits_for_the_key_after_its_modifiers() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let mut d = Dialog::new("keyboardShortcuts", json!({"query": "", "recording": "app.palette"}));
let ctx = egui::Context::default();
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
// Holding Ctrl reports Ctrl itself as a key press, then Ctrl+K arrives.
for event in [key(egui::Key::ControlLeft, egui::Modifiers::COMMAND), key(egui::Key::K, egui::Modifiers::COMMAND)] {
let input = egui::RawInput { events: vec![event], ..Default::default() };
ctx.run_ui(input, |ui| keyboard_shortcuts(&mut app, ui, &mut d)).textures_delta.clear();
}
assert_eq!(crate::menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+K"));
assert_eq!(d.s("recording"), "", "recording ends with the recorded shortcut");
}
}
+20
View File
@@ -230,6 +230,26 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Business Card", ["Business Card", "Business Card", "Business Card", "Business Card", "名片"]),
("CMYK", ["CMYK", "CMYK", "CMYK", "CMYK", "CMYK"]),
("Cancel", ["Cancel", "Cancel", "Cancel", "Cancel", "取消"]),
(
"Can't Open the File",
[
"Datei kann nicht geöffnet werden",
"Impossible d'ouvrir le fichier",
"No se puede abrir el archivo",
"ファイルを開けません",
"无法打开文件",
],
),
(
"Can't Place the File",
[
"Datei kann nicht platziert werden",
"Impossible d'importer le fichier",
"No se puede colocar el archivo",
"ファイルを配置できません",
"无法置入文件",
],
),
("Center Content", ["Center Content", "Center Content", "Center Content", "Center Content", "内容居中"]),
("Change All", ["Change All", "Change All", "Change All", "Change All", "全部更改"]),
("Change to:", ["Change to:", "Change to:", "Change to:", "Change to:", "更改为:"]),
+2
View File
@@ -167,6 +167,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Dock panel", "إرساء اللوحة"),
("OK", "موافق"),
("Cancel", "إلغاء"),
("Can't Open the File", "تعذّر فتح الملف"),
("Can't Place the File", "تعذّر إدراج الملف"),
("Apply", "تطبيق"),
("Done", "تم"),
("Reset", "إعادة ضبط"),
+2
View File
@@ -805,6 +805,8 @@ pub fn button(ui: &mut egui::Ui, name: &str, size: f32, selected: bool, tip: &st
}
let pad = size * 0.16;
paint(ui.painter(), r.shrink(pad), name, if selected { t.text_strong } else { t.icon });
let enabled = ui.is_enabled();
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, tip));
if tip.is_empty() {
resp
} else {
+198 -15
View File
@@ -58,8 +58,8 @@ pub struct Services {
/// Hand bytes to the user as a named file (browser download). When set, Save uses it
/// instead of writing to a path.
pub download: Option<DownloadFn>,
/// Files delivered asynchronously, drained every frame: `.designcraft` → `file.openBytes`,
/// anything else → `file.place`.
/// Files delivered asynchronously, drained every frame: documents ([`opens_as_document`]) →
/// `file.openBytes`, `.ase` → `swatch.load`, anything else → `file.place`.
pub inbox: Option<Inbox>,
}
@@ -539,10 +539,10 @@ impl DesignApp {
self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages})));
Ok(Value::Null)
} else {
self.run(cmd, json!({"path": path}))
self.open_file(cmd, json!({"path": path}))
}
}
Some(path) => self.run(cmd, json!({"path": path})),
Some(path) => self.open_file(cmd, json!({"path": path})),
None => Ok(Value::Null),
};
}
@@ -561,11 +561,11 @@ impl DesignApp {
let lower = name.to_ascii_lowercase();
let r = if lower.ends_with(".ase") {
self.run("swatch.load", json!({"base64": b64}))
} else if lower.ends_with(".designcraft") || lower.ends_with(".idml") {
} else if opens_as_document(&name) {
let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem);
self.run("file.openBytes", json!({"name": title, "base64": b64}))
self.open_file("file.openBytes", json!({"name": title, "base64": b64}))
} else {
self.run("file.place", json!({"name": name, "base64": b64}))
self.open_file("file.place", json!({"name": name, "base64": b64}))
};
if let Err(e) = r {
self.status(format!("{name}: {e}"));
@@ -636,18 +636,34 @@ impl DesignApp {
}
#[cfg(not(target_arch = "wasm32"))]
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
{
let p = f.path().to_string_lossy().to_string();
if p.is_empty() {
continue;
}
let lp = p.to_ascii_lowercase();
let cmd = if lp.ends_with(".designcraft") || lp.ends_with(".idml") { "file.open" } else { "file.place" };
let _ = self.run(cmd, json!({"path": p}));
let p = f.path().to_string_lossy().to_string();
if !p.is_empty() {
let _ = self.open_dropped(&p);
}
}
}
/// A file dropped on the window: documents open, anything else is placed.
pub fn open_dropped(&mut self, path: &str) -> Result<Value, String> {
let cmd = if opens_as_document(path) { "file.open" } else { "file.place" };
self.open_file(cmd, json!({"path": path}))
}
/// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file
/// that can't be opened or placed gets an alert with the reason, as well as the status line.
pub fn open_file(&mut self, cmd: &str, params: Value) -> Result<Value, String> {
let file = match params.get("path").and_then(Value::as_str) {
Some(path) => std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()),
None => params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
};
let r = self.run(cmd, params);
if let Err(e) = &r {
let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" };
self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e})));
}
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() {
@@ -856,6 +872,13 @@ impl DesignApp {
}
}
/// Files that open as documents (when dropped or picked) rather than being placed: DesignCraft
/// and IDML.
pub fn opens_as_document(name: &str) -> bool {
let n = name.to_ascii_lowercase();
[".designcraft", ".idml"].iter().any(|ext| n.ends_with(ext))
}
pub fn now_ms() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -868,10 +891,42 @@ pub fn now_ms() -> f64 {
}
}
/// Is `text` the name of a key ("AltGraph", "Dead", "AudioVolumeUp") instead of typed text?
/// Browsers report a key press as a string: the character it types, or a name made of ASCII
/// letters and digits that starts with a capital. One key press never types such a word.
pub fn is_key_name(text: &str) -> bool {
text.len() > 1 && text.starts_with(|c: char| c.is_ascii_uppercase()) && text.chars().all(|c| c.is_ascii_alphanumeric())
}
/// Drop text events that are key names. eframe's web backend sends the name of every key it
/// doesn't know as text, so AltGr typed "AltGraph" into the story. `text_field_focused`: an
/// egui text field has the keyboard; its text comes from the browser's input events, which can
/// hold whole words, and is left alone.
pub fn drop_key_name_text(raw: &mut egui::RawInput, text_field_focused: bool) {
if !text_field_focused {
raw.events.retain(|e| !matches!(e, egui::Event::Text(t) if is_key_name(t)));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_names_sent_as_text_are_dropped() {
let text = |t: &str| egui::Event::Text(t.to_string());
let events = || {
vec![text("AltGraph"), text("é"), text("Dead"), text("A"), text("Unidentified"), text("ß"), text("AudioVolumeUp"), text("ab"), text("Æ")]
};
let mut raw = egui::RawInput { events: events(), ..Default::default() };
drop_key_name_text(&mut raw, false);
assert_eq!(raw.events, vec![text("é"), text("A"), text("ß"), text("ab"), text("Æ")]);
// A focused text field gets its text from input events, which can be whole words.
let mut raw = egui::RawInput { events: events(), ..Default::default() };
drop_key_name_text(&mut raw, true);
assert_eq!(raw.events, events());
}
#[test]
fn snap_view_uses_the_saved_switches() {
let mut ui = UiState::default();
@@ -883,4 +938,132 @@ mod tests {
assert!(!ui.snap_view().snap_to_guides);
assert_eq!(ui.snap_view().zone_px, 0.0);
}
/// A temporary file named `name` whose bytes aren't a document or a graphic.
fn unreadable_file(name: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("designcraft-ui-{}-{name}", std::process::id()));
std::fs::write(&path, b"neither a document nor a graphic").unwrap();
path
}
/// The open alert dialog: (title, message).
fn alert(app: &DesignApp) -> (String, String) {
let d = app.ui.dialog.as_ref().expect("an alert is open");
assert_eq!(d.id, "alert");
(d.fields["title"].as_str().unwrap().to_string(), d.fields["message"].as_str().unwrap().to_string())
}
#[test]
fn dropped_documents_open_and_the_rest_is_placed() {
for name in ["a.designcraft", "B.IDML"] {
assert!(opens_as_document(name), "{name}");
}
for name in ["a.png", "b.pdf", "idml.txt", "c.idml.zip"] {
assert!(!opens_as_document(name), "{name}");
}
}
#[test]
fn dropping_an_unreadable_file_alerts() {
// A document opens, so it says it can't be opened.
let path = unreadable_file("broken.designcraft");
let mut app = DesignApp::new(Session::new(), Services::default());
let r = app.open_dropped(&path.to_string_lossy());
let _ = std::fs::remove_file(&path);
let e = r.unwrap_err();
let (title, message) = alert(&app);
assert_eq!(title, "Can't Open the File", "a document opens, it isn't placed");
assert_eq!(message, e);
assert!(app.ui.status.contains(&e), "the status line says it too");
assert!(app.session.documents().is_empty());
dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "OK closes the alert");
// Anything else is placed, so it says it can't be placed.
app.run("file.new", json!({})).unwrap();
let path = unreadable_file("notes.xyz");
let r = app.open_dropped(&path.to_string_lossy());
let _ = std::fs::remove_file(&path);
let e = r.unwrap_err();
assert_eq!(alert(&app), ("Can't Place the File".to_string(), e));
}
#[test]
fn file_open_failures_show_an_alert() {
let path = unreadable_file("open.designcraft");
let picked = path.to_string_lossy().to_string();
let services = Services { pick_open: Some(Box::new(move |_| Some(picked.clone()))), ..Default::default() };
let mut app = DesignApp::new(Session::new(), services);
let e = app.run("app.openDialog", json!({})).unwrap_err();
assert_eq!(alert(&app), ("Can't Open the File".to_string(), e));
// Placing it says so too.
app.ui.dialog = None;
app.run("file.new", json!({})).unwrap();
let e = app.run("app.placeDialog", json!({})).unwrap_err();
let _ = std::fs::remove_file(&path);
assert_eq!(alert(&app), ("Can't Place the File".to_string(), e));
}
#[test]
fn other_command_errors_stay_in_the_status_line() {
let mut app = DesignApp::new(Session::new(), Services::default());
assert!(app.run("file.open", json!({"path": "/nonexistent/x.designcraft"})).is_err());
assert!(app.ui.dialog.is_none(), "a scripted file.open doesn't raise an alert");
assert!(!app.ui.status.is_empty());
// The control channel's app.open answers with the error; no alert waits for a click.
let (req, _rx) = control::ControlRequest::new("app.open", json!({"path": "/nonexistent/x.designcraft"}));
let control::Outcome::Done(r) = control::handle(&mut app, &egui::Context::default(), &req) else { panic!("app.open answers") };
assert_eq!(r["ok"], json!(false), "{r}");
assert!(app.ui.dialog.is_none(), "a control-channel app.open doesn't raise an alert");
}
}
/// The whole window in a headless test harness, at a chosen window size.
#[cfg(test)]
pub(crate) mod test_window {
use egui_kittest::Harness;
pub struct Window {
pub app: crate::DesignApp,
ready: bool,
}
/// The window at `size` with a new document open.
pub fn open(mut app: crate::DesignApp, size: egui::Vec2) -> Harness<'static, Window> {
if app.session.active().is_none() {
app.run("file.new", serde_json::json!({})).unwrap();
}
let mut h = Harness::builder().with_size(size).with_max_steps(1000).build_ui_state(
|ui, w: &mut Window| {
// The builder runs a frame before the texture size can be raised: skip it.
if !w.ready {
return;
}
let ctx = ui.ctx().clone();
w.app.logic(&ctx);
w.app.ui(ui);
},
Window { app, ready: false },
);
h.input_mut().max_texture_side = Some(8192);
h.state_mut().ready = true;
h.run_steps(6);
h
}
/// The mouse wheel turned over `pos`: positive `delta` moves the content right and down.
pub fn wheel(h: &mut Harness<'static, Window>, pos: egui::Pos2, delta: egui::Vec2) {
h.hover_at(pos);
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta,
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(10);
}
/// The outer rect of a side or top panel, from egui's memory.
pub fn panel_rect(h: &Harness<'static, Window>, id: &str) -> egui::Rect {
egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new(id)).map(|s| s.outer_rect).unwrap()
}
}
+275 -4
View File
@@ -704,7 +704,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) else { return Some(Ok(Value::Null)) };
let l = path.to_lowercase();
if !(l.ends_with(".docx") || l.ends_with(".rtf")) {
return Some(app.run("file.place", json!({"path": path})));
return Some(app.open_file("file.place", json!({"path": path})));
}
let info = match app.run("place.styles", json!({"path": path})) {
Ok(v) => v,
@@ -1360,10 +1360,20 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
}
// Workspaces choose which bars and panels are visible.
app.ui.control_bar = matches!(name, "Advanced" | "Typography" | "Printing and Proofing" | "Book");
app.ui.dock_tab = if name == "Typography" { "properties".into() } else { app.ui.dock_tab.clone() };
let dock_tab = match name {
"Typography" => Some("properties"),
"Interactive for PDF" | "Digital Publishing" => Some("pages"),
_ => None,
};
if let Some(tab) = dock_tab {
app.ui.dock_tab = tab.into();
app.ui.dock_expanded = true;
}
app.ui.open_panel = match name {
"Typography" => Some("paragraphStyles".into()),
"Printing and Proofing" => Some("swatches".into()),
"Interactive for PDF" => Some("buttons".into()),
"Digital Publishing" => Some("liquid".into()),
_ => None,
};
app.ui.workspace = name.to_string();
@@ -1393,7 +1403,7 @@ fn download_document(app: &mut DesignApp) -> Result<Value, String> {
fn export_png(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let page = p.get("page").and_then(Value::as_u64).map(|v| v as usize).or_else(|| crate::canvas::current_page(app)).unwrap_or(0);
let page = crate::control::page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count())?;
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(2.0);
let mut r = designcraft_render::Renderer::new();
let img = r
@@ -1743,6 +1753,44 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
let _ = app.run(id, p);
}
/// A native menu bar item was chosen (macOS): like [`activate`], except that the menu bar takes
/// ⌘A, ⌘C, ⌘X, ⌘V, ⌘Z and ⇧⌘Z before the window sees them, so while a text field has keyboard
/// focus, Select All, Copy, Cut, Paste, Undo and Redo act on that field, as in any Mac app.
pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, params: &Value) {
if !(ctx.text_edit_focused() && params.is_null() && forward_to_text_field(app, ctx, id)) {
activate(app, id, params);
}
}
/// Whether a native menu bar item can be chosen: as in the in-window menus, but the editing
/// commands a focused text field takes are always available to it.
pub fn native_menu_enabled(app: &DesignApp, ctx: &egui::Context, id: &str) -> bool {
(ctx.text_edit_focused() && TEXT_FIELD_COMMANDS.contains(&id)) || menu_enabled(app, id)
}
/// The editing commands a focused text field takes from the native menu bar.
const TEXT_FIELD_COMMANDS: [&str; 6] = ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"];
/// Hand a standard editing command to the focused text field as next frame's input; false when
/// `id` isn't one of them.
fn forward_to_text_field(app: &mut DesignApp, ctx: &egui::Context, id: &str) -> bool {
let key = |key: egui::Key, modifiers: egui::Modifiers| {
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers })
};
match id {
"edit.selectAll" => app.synthetic.extend(key(egui::Key::A, egui::Modifiers::COMMAND)),
"edit.undo" => app.synthetic.extend(key(egui::Key::Z, egui::Modifiers::COMMAND)),
"edit.redo" => app.synthetic.extend(key(egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT)),
"edit.copy" => app.synthetic.push(egui::Event::Copy),
"edit.cut" => app.synthetic.push(egui::Event::Cut),
// Only the integration reads the system clipboard: it answers with an `Event::Paste`.
"edit.paste" => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
_ => return false,
}
ctx.request_repaint();
true
}
/// The menu bar contents (inside the app bar; macOS uses the native menu instead).
pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
let lang = app.ui.language.clone();
@@ -1896,6 +1944,9 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
if app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) {
continue; // The native menu handles it.
}
// The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's
// default button) must not see the same press as Enter or Space this frame.
ctx.input_mut(|i| i.consume_key(modifiers, key));
// Like choosing the menu item: "…" commands open their dialog.
activate(app, &id, &Value::Null);
continue;
@@ -1931,6 +1982,13 @@ pub fn palette(app: &mut DesignApp, ctx: &egui::Context) {
);
r.request_focus();
let items = quick_apply_items(&app.session, &q);
// Only a fresh Return runs the first entry: ⌘Return pressed again or held down (repeats) is
// the shortcut, not a choice.
let enter = ui.input(|i| {
i.events
.iter()
.any(|e| matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, repeat: false, modifiers, .. } if !modifiers.command))
});
egui::ScrollArea::vertical().max_height(340.0).show(ui, |ui| {
for (i, it) in items.iter().enumerate() {
let mut b = egui::Button::new(it.label.as_str()).frame(false);
@@ -1939,7 +1997,13 @@ pub fn palette(app: &mut DesignApp, ctx: &egui::Context) {
} else if !it.kind.is_empty() {
b = b.shortcut_text(it.kind);
}
if ui.add_sized([460.0, 22.0], b).clicked() || (i == 0 && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
// The button aligns its label by the layout it's in: left, like the rows with a shortcut.
let size = egui::vec2(460.0, 22.0);
let row =
ui.allocate_ui_with_layout(size, egui::Layout::left_to_right(egui::Align::Center).with_main_align(egui::Align::Min), |ui| {
ui.add(b.min_size(size))
});
if row.inner.clicked() || (i == 0 && enter) {
run = Some((it.id.clone(), it.params.clone()));
}
}
@@ -2380,6 +2444,78 @@ mod tests {
assert_eq!(app.session.tool_id(), "rectangleFrame");
}
#[test]
fn the_quick_apply_shortcut_opens_it_without_running_an_entry() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
let cmd_return =
|pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND };
frame(&mut app, vec![]);
frame(&mut app, vec![]);
frame(&mut app, vec![cmd_return(true), cmd_return(false)]);
frame(&mut app, vec![]);
assert_eq!(app.ui.palette.as_deref(), Some(""), "Quick Apply stays open");
assert_eq!(app.session.documents().len(), 1, "the first entry (New Document) didn't run");
// Pressing the shortcut again, or holding it until it repeats, doesn't run it either.
frame(&mut app, vec![cmd_return(true), cmd_return(false)]);
let repeat = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: true, modifiers: egui::Modifiers::COMMAND };
frame(&mut app, vec![repeat, cmd_return(false)]);
assert_eq!(app.ui.palette.as_deref(), Some(""), "Quick Apply stays open");
assert_eq!(app.session.documents().len(), 1, "a second ⌘Return or a repeat doesn't run the first entry");
// A plain Return still does.
let enter =
|pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE };
frame(&mut app, vec![enter(true), enter(false)]);
assert_eq!(app.ui.palette, None, "Return runs the first entry and closes Quick Apply");
assert_eq!(app.session.documents().len(), 2);
}
#[test]
fn quick_apply_rows_are_left_aligned() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({})).unwrap();
app.ui.palette = Some(String::new());
let ctx = egui::Context::default();
let mut shapes = Vec::new();
for _ in 0..3 {
let input =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
shapes = out.shapes;
}
let label_x = |label: &str| {
// The palette is painted last, over anything else with the same text.
shapes.iter().rev().find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == label => Some(t.pos.x),
_ => None,
})
};
// Rows that show a shortcut or a style kind, and rows that show neither, among the first visible ones.
let items = quick_apply_items(&app.session, "");
let first = items.iter().take(10);
let plain = first.clone().find(|it| it.shortcut.is_none() && it.kind.is_empty()).expect("a row without a shortcut");
let with_sc = first.clone().find(|it| it.shortcut.is_some()).expect("a row with a shortcut");
let (a, b) = (label_x(&plain.label).expect("plain row drawn"), label_x(&with_sc.label).expect("shortcut row drawn"));
assert!((a - b).abs() < 0.5, "{:?} at x {a}, {:?} at x {b}", plain.label, with_sc.label);
}
#[test]
fn sample_scripts_run() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -2433,6 +2569,129 @@ mod tests {
assert!(run_ui(&mut app, "window.floatPanel", &json!({"panel": "nope"})).unwrap().is_err());
}
/// One frame as the desktop app runs it: queued synthetic input, then the native menu item
/// chosen during the frame (`menu`), then the app's logic and ui.
fn native_frame(app: &mut crate::DesignApp, ctx: &egui::Context, events: Vec<egui::Event>, menu: Option<&str>) -> egui::FullOutput {
let mut input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
app.raw_input_hook(&mut input);
let mut menu = menu;
let mut out = ctx.run_ui(input, |ui| {
let ctx = ui.ctx().clone();
if let Some(id) = menu.take() {
activate_native(app, &ctx, id, &Value::Null);
}
app.logic(&ctx);
app.ui(ui);
});
out.textures_delta.clear();
out
}
/// A document with two frames, nothing selected, and Quick Apply's search field focused
/// holding `text`.
fn app_with_focused_field(ctx: &egui::Context, text: &str) -> crate::DesignApp {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({})).unwrap();
app.session.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap();
app.session.execute("frame.create", &json!({"rect": [236, 36, 400, 200]})).unwrap();
app.session.execute("edit.deselectAll", &json!({})).unwrap();
app.ui.palette = Some(text.into());
for _ in 0..3 {
native_frame(&mut app, ctx, vec![], None);
}
assert!(ctx.text_edit_focused());
app
}
fn field_selection(ctx: &egui::Context) -> Option<(usize, usize)> {
let id = ctx.memory(|m| m.focused())?;
let r = egui::TextEdit::load_state(ctx, id)?.cursor.char_range()?;
Some((r.primary.index.min(r.secondary.index).into(), r.primary.index.max(r.secondary.index).into()))
}
fn document_items(app: &crate::DesignApp) -> (usize, usize) {
let d = app.session.active().unwrap();
(d.doc.spreads.iter().flat_map(|sp| &sp.items).count(), d.selection.items.len())
}
#[test]
fn native_select_all_selects_the_focused_fields_text() {
// The Mac menu bar takes ⌘A before the window sees it (#30).
let ctx = egui::Context::default();
let mut app = app_with_focused_field(&ctx, "frame");
native_frame(&mut app, &ctx, vec![], Some("edit.selectAll"));
native_frame(&mut app, &ctx, vec![], None);
assert_eq!(field_selection(&ctx), Some((0, 5)), "the field's whole text is selected");
assert_eq!(document_items(&app), (2, 0), "the document selection is unchanged");
}
#[test]
fn native_paste_and_copy_act_on_the_focused_field() {
let ctx = egui::Context::default();
let mut app = app_with_focused_field(&ctx, "frame");
native_frame(&mut app, &ctx, vec![], Some("edit.selectAll"));
native_frame(&mut app, &ctx, vec![], None);
// Copy puts the field's selected text on the clipboard.
native_frame(&mut app, &ctx, vec![], Some("edit.copy"));
let out = native_frame(&mut app, &ctx, vec![], None);
assert!(out.platform_output.commands.contains(&egui::OutputCommand::CopyText("frame".into())), "{:?}", out.platform_output.commands);
// Paste asks the integration for the clipboard, which it hands to the field as a paste.
let out = native_frame(&mut app, &ctx, vec![], Some("edit.paste"));
let commands = &out.viewport_output[&egui::ViewportId::ROOT].commands;
assert!(commands.contains(&egui::ViewportCommand::RequestPaste), "{commands:?}");
native_frame(&mut app, &ctx, vec![egui::Event::Paste("text".into())], None);
assert_eq!(app.ui.palette.as_deref(), Some("text"));
assert_eq!(document_items(&app), (2, 0), "nothing was pasted into the document");
}
#[test]
fn native_undo_and_redo_act_on_the_focused_field() {
let ctx = egui::Context::default();
let mut app = app_with_focused_field(&ctx, "");
native_frame(&mut app, &ctx, vec![egui::Event::Text("abc".into())], None);
assert_eq!(app.ui.palette.as_deref(), Some("abc"));
native_frame(&mut app, &ctx, vec![], Some("edit.undo"));
native_frame(&mut app, &ctx, vec![], None);
assert_eq!(app.ui.palette.as_deref(), Some(""), "the field's typing is undone");
assert_eq!(document_items(&app), (2, 0), "the document's last frame is still there");
native_frame(&mut app, &ctx, vec![], Some("edit.redo"));
native_frame(&mut app, &ctx, vec![], None);
assert_eq!(app.ui.palette.as_deref(), Some("abc"));
assert_eq!(document_items(&app), (2, 0));
}
#[test]
fn native_editing_items_are_enabled_for_a_focused_field() {
let ctx = egui::Context::default();
let mut app = app_with_focused_field(&ctx, "frame");
// Nothing is selected in the document, so its Copy and Cut are off; the field's are on.
assert!(!menu_enabled(&app, "edit.copy") && !menu_enabled(&app, "edit.cut"));
for id in ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"] {
assert!(native_menu_enabled(&app, &ctx, id), "{id}");
}
assert!(!native_menu_enabled(&app, &ctx, "edit.duplicate"), "other items follow the document");
app.ui.palette = None;
native_frame(&mut app, &ctx, vec![], None);
native_frame(&mut app, &ctx, vec![], None);
assert!(!native_menu_enabled(&app, &ctx, "edit.copy"), "without a focused field, as the document says");
}
#[test]
fn native_select_all_without_a_focused_field_selects_the_documents_items() {
let ctx = egui::Context::default();
let mut app = app_with_focused_field(&ctx, "frame");
app.ui.palette = None;
native_frame(&mut app, &ctx, vec![], None);
native_frame(&mut app, &ctx, vec![], None);
assert!(!ctx.text_edit_focused());
native_frame(&mut app, &ctx, vec![], Some("edit.selectAll"));
assert_eq!(document_items(&app), (2, 2));
}
#[test]
fn quick_apply_lists_styles_then_commands() {
let mut s = designcraft_engine::Session::new();
@@ -2489,6 +2748,18 @@ mod tests {
assert_eq!(app.ui.workspace, "Essentials");
}
#[test]
fn interactive_workspaces_show_their_panels() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.dock_expanded = false;
run_ui(&mut app, "window.workspace", &json!({"name": "Interactive for PDF"})).unwrap().unwrap();
assert_eq!((app.ui.dock_tab.as_str(), app.ui.dock_expanded, app.ui.open_panel.as_deref()), ("pages", true, Some("buttons")));
run_ui(&mut app, "window.workspace", &json!({"name": "Digital Publishing"})).unwrap().unwrap();
assert_eq!((app.ui.dock_tab.as_str(), app.ui.open_panel.as_deref()), ("pages", Some("liquid")));
run_ui(&mut app, "window.workspace", &json!({"name": "Essentials"})).unwrap().unwrap();
assert_eq!(app.ui.open_panel, None);
}
#[test]
fn hide_and_show_menu_items() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+63 -7
View File
@@ -1070,6 +1070,18 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) {
}) {
let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
}
// Language: hyphenation, spelling and typographer's quotes follow it.
ui.add_space(1.0);
let lang = c["language"].as_str().unwrap_or("English: USA").to_string();
let label = crate::i18n::tr(&app.ui.language, "Language");
let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &lang), fw).on_hover_ui(|ui| {
crate::rtl::label(ui, label);
});
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label));
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(fw);
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| language_menu(app, ui, &lang));
});
if more_options(ui, &app.ui.language).clicked() {
app.ui.open_panel = Some("character".into());
}
@@ -1212,6 +1224,17 @@ const LANGUAGES: &[&str] = &[
"Hebrew",
];
/// The Language list (Character panel, Properties ▸ Character): picking one sets the language of
/// the selected text.
fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) {
for l in LANGUAGES {
if ui.selectable_label(*l == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
let _ = app.run("type.char", json!({"attrs": {"language": l}}));
ui.close();
}
}
}
pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let Some(a) = text_attrs(app) else {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select text or a text frame."));
@@ -1231,13 +1254,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
egui::ComboBox::from_id_salt("char_language")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &lang)))
.width(170.0)
.show_ui(ui, |ui| {
for l in LANGUAGES {
if ui.selectable_label(*l == lang, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() {
let _ = app.run("type.char", json!({"attrs": {"language": l}}));
}
}
});
.show_ui(ui, |ui| language_menu(app, ui, &lang));
});
// Digits (World-Ready): shown for right-to-left paragraphs or once set.
let digits = c["digits"].as_str().unwrap_or("default").to_string();
@@ -2120,3 +2137,42 @@ fn variable_font_axes(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, styl
let _ = app.run("type.char", json!({"attrs": {"fontStyle": format!("{base} {{{spec}}}")}}));
}
}
#[cfg(test)]
mod tests {
use egui_kittest::{Harness, kittest::Queryable};
use super::*;
fn language(app: &mut DesignApp) -> Value {
text_attrs(app).unwrap()["chars"]["language"].clone()
}
#[test]
fn properties_character_section_sets_the_language() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "bunun", "caret": true})).unwrap();
app.run("edit.selectAll", json!({})).unwrap();
assert_ne!(language(&mut app), "French");
let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state(
|ui, app: &mut DesignApp| {
// The panel's fonts are installed before its first frame.
let id = egui::Id::new("test_fonts");
if ui.data(|d| d.get_temp::<bool>(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.get_by_label("Language").click();
h.run_steps(2);
h.get_by_label("French").click();
h.run_steps(2);
assert_eq!(language(h.state_mut()), "French");
}
}
+10
View File
@@ -191,6 +191,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
}
});
divider(ui);
// Column order: right to left puts the first column on the right.
let rtl = info["options"]["direction"] == "rightToLeft";
ui.horizontal(|ui| {
caption(ui, crate::i18n::tr(&app.ui.language, "Direction"));
for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] {
if ui.selectable_label(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() && !on {
run(app, "table.options", json!({"direction": dir}));
}
}
});
let mut alt = !info["options"]["altRows"].is_null();
if ui.checkbox(&mut alt, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Alternating Row Fills"))).changed() {
let v = if alt { json!({"first": 1, "firstColor": "[Black]", "firstTint": 0.1, "next": 1, "nextColor": "[None]"}) } else { Value::Null };
+109 -17
View File
@@ -12,27 +12,33 @@ const BTN: f32 = 24.0;
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let cols = if app.ui.tools_double_column { 2 } else { 1 };
let frame = egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.divider));
// A single column that doesn't fit the window's height becomes two, without changing the
// preference; whatever two columns still can't fit scrolls.
let single_h_id = egui::Id::new("tools_single_column_height");
let single_h: Option<f32> = ui.data(|d| d.get_temp(single_h_id));
let viewport_h = ui.available_height() - frame.total_margin().sum().y;
let crowded = !app.ui.tools_double_column && single_h.is_some_and(|h| h > viewport_h);
let cols = if app.ui.tools_double_column || crowded { 2 } else { 1 };
let width = 8.0 + BTN * cols as f32 + 4.0 * (cols as f32 - 1.0) + 8.0;
let r = egui::Panel::left("tools")
.exact_size(width)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.divider)))
.show(ui, |ui| {
let r = egui::Panel::left("tools").exact_size(width).resizable(false).frame(frame).show(ui, |ui| {
crate::widgets::overflow_scrolling(ui);
let scroll = egui::ScrollArea::vertical().id_salt("tools_scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 1.0;
ui.add_space(3.0);
// Collapse chevrons.
// Collapse chevrons (nothing to toggle while the window forces two columns).
ui.horizontal(|ui| {
ui.add_space(width / 2.0 - 8.0);
if icons::button(
ui,
if cols == 1 { "double-chevron-right" } else { "double-chevron-left" },
16.0,
false,
crate::i18n::tr(&app.ui.language, "Toggle double column"),
)
.clicked()
{
let toggle = ui.add_enabled_ui(!crowded, |ui| {
icons::button(
ui,
if cols == 1 { "double-chevron-right" } else { "double-chevron-left" },
16.0,
false,
crate::i18n::tr(&app.ui.language, "Toggle double column"),
)
});
if toggle.inner.clicked() {
app.ui.tools_double_column = !app.ui.tools_double_column;
}
});
@@ -169,6 +175,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.add_space(8.0);
fill_stroke_proxy(app, ui, width);
});
if cols == 1 {
let h = scroll.content_size.y;
ui.data_mut(|d| d.insert_temp(single_h_id, h));
// Laid out in one column that turned out too tall: lay out again in two.
if h > viewport_h {
ui.ctx().request_discard("tools panel: one column does not fit");
}
}
});
if std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some() {
eprintln!("tools panel rect {:?} (wanted width {width}); remaining {:?}", r.response.rect, ui.available_rect_before_wrap());
}
@@ -252,9 +267,11 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) {
paint_stroke(p);
paint_fill(p);
}
let fill_tip = crate::i18n::tr(&app.ui.language, "Fill (X)");
let fresp = ui.interact(fill_r, ui.id().with("proxy_fill"), Sense::click()).on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fill (X)"));
crate::rtl::label(ui, fill_tip);
});
fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fill_tip));
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)"));
});
@@ -387,7 +404,82 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui::
ui.painter().rect_filled(egui::Rect::from_center_size(r.center(), egui::vec2(28.0, 20.0)), 2.0, t.hover);
}
icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(17.0, 17.0)), icon, if active { t.text_strong } else { t.icon });
let enabled = ui.is_enabled();
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip));
resp.on_hover_ui(|ui| {
crate::rtl::label(ui, tip);
})
}
#[cfg(test)]
mod tests {
use egui::{pos2, vec2};
use egui_kittest::kittest::Queryable;
use crate::test_window::{self, wheel};
/// The last tool group's button and the Fill proxy, near the bottom of the panel.
const LAST_TOOL: &str = "Zoom Tool (Z)";
const FILL: &str = "Fill (X)";
/// Status bar height: the Tools panel ends above it.
const STATUS_H: f32 = 17.0;
fn app(double: bool) -> crate::DesignApp {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.tools_double_column = double;
app
}
fn column_width(cols: f32) -> f32 {
8.0 + super::BTN * cols + 4.0 * (cols - 1.0) + 8.0
}
fn rect(h: &egui_kittest::Harness<'static, test_window::Window>, label: &str) -> egui::Rect {
h.get_by_label(label).rect()
}
#[test]
fn a_short_window_reaches_every_tool() {
for double in [false, true] {
let (w, ht) = (1000.0, 500.0);
let mut h = test_window::open(app(double), vec2(w, ht));
let panel = test_window::panel_rect(&h, "tools");
wheel(&mut h, panel.center(), vec2(0.0, -2000.0));
let visible = egui::Rect::from_min_max(pos2(0.0, panel.min.y), pos2(w, ht - STATUS_H));
for label in [LAST_TOOL, FILL] {
let r = rect(&h, label);
assert!(visible.contains_rect(r), "double column {double}: {label} at {r:?} is outside {visible:?}");
}
assert_eq!(h.state().app.ui.tools_double_column, double, "the preference is the user's");
}
}
#[test]
fn tools_take_two_columns_while_one_does_not_fit() {
let (w, ht) = (1086.0, 612.0);
let mut h = test_window::open(app(false), vec2(w, ht));
assert_eq!(test_window::panel_rect(&h, "tools").width(), column_width(2.0));
let visible = egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(w, ht - STATUS_H));
for label in [LAST_TOOL, FILL] {
let r = rect(&h, label);
assert!(visible.contains_rect(r), "{label} at {r:?} is outside {visible:?} without scrolling");
}
assert!(!h.state().app.ui.tools_double_column);
// A taller window brings the single column back.
h.set_size(vec2(1400.0, 900.0));
h.run_steps(4);
assert_eq!(test_window::panel_rect(&h, "tools").width(), column_width(1.0));
assert!(!h.state().app.ui.tools_double_column);
}
#[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));
let panel = test_window::panel_rect(&h, "tools");
assert_eq!(panel.width(), column_width(1.0));
let before = (rect(&h, LAST_TOOL), rect(&h, FILL));
assert!(panel.contains_rect(before.0) && panel.contains_rect(before.1), "{before:?} outside {panel:?}");
wheel(&mut h, panel.center(), vec2(0.0, -2000.0));
assert_eq!((rect(&h, LAST_TOOL), rect(&h, FILL)), before);
}
}
+15
View File
@@ -572,6 +572,21 @@ pub fn segmented(ui: &mut Ui, labels: &[&str], active: usize, width: f32) -> Opt
out
}
/// Scrolling for a bar that a small window cuts off (Tools panel, Control panel, application
/// bar): the mouse wheel scrolls the bar's only direction, and a thin scroll bar floats over the
/// contents, so nothing moves and nothing shows while everything fits.
pub fn overflow_scrolling(ui: &mut Ui) {
let style = ui.style_mut();
style.always_scroll_the_only_direction = true;
// A caption at the end of a scrolled row gets only what is left of it: extend, don't wrap.
style.wrap_mode = Some(egui::TextWrapMode::Extend);
let s = &mut style.spacing.scroll;
*s = egui::style::ScrollStyle::floating();
s.bar_width = 6.0;
// A faint handle while the pointer is elsewhere: the bar shows there is more.
s.dormant_handle_opacity = 0.5;
}
#[cfg(test)]
mod tests {
use super::*;
+4
View File
@@ -41,6 +41,10 @@ The result is JSON: `{"completed": n, "results": [...]}`, plus `failedIndex` / `
fails (the exit status is non-zero; `--keep-going` records errors and continues). `run --cmd ID=JSON` accepts the
same references, and MCP's `batch` tool takes `commands` or `script` text with them.
`run` works through its arguments in order. `--page`, `--scale` and `--pdf-options` apply to the `--export` /
`--all-pages` that follow them, so one run can write several sizes
(`run --sample --scale 0.5 --export small.png --scale 2 --export large.png`); one with no export after it is an error.
## Data merge
`data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it,
+9 -1
View File
@@ -31,7 +31,7 @@ Other MCP clients use the same command line, e.g. in a JSON config:
| Engine | In-process `designcraft_engine::Session`, starts with an empty Letter document (`--sample` opens the sample magazine) | The app's session, through its loopback control channel ([control-protocol.md](control-protocol.md)) |
| Rendering | `designcraft-render` (CPU) | The app's renderer |
| Window tools (`screenshot`, `click`, `drag`, `menu_list`, `ui_inspect`, `ui_set`, `dialog_*`) | Return an error explaining how to connect | Work |
| UI-only commands (`view.*`, `window.*`, `app.*`) via `execute` | Error | Work |
| UI-only commands via `execute` (`view.*`, `window.*`, `help.*`, `edit.dynamicSpelling`, and `app.*` except `app.links`) | Error | Work |
In connected mode every tool maps to a control-channel method (`engine.execute`, `ui.render`, `ui.pointer`, …), so
what the agent does shows up live in the window. The connection is re-established once if the app restarts.
@@ -43,6 +43,14 @@ spread the right page starts at x = page width. `inspect_document` lists page bo
`render_page` / `export_png` are 0-based. `pointer` events in connected mode may use `"space": "screen"` (egui points,
see `ui_inspect` → `canvasRect`).
## Text offsets
Positions in a story's text are **UTF-8 byte offsets**, not character counts: `anchor` / `focus` of `text.select`,
`start` / `end` of `story.replaceRange` and of `find.find` matches, the `pos` that `text.placeCaret`, `text.insert`
and `text.move` return, a story's `length` in `get_story` and `inspect_document`, and its `overset` position in
`get_story`. A character outside ASCII takes 2 to 4 bytes (é 2, — and は 3), so the story `はただ商店` has length 15
and its second character starts at 3. An offset inside a character snaps back to the start of that character.
## Tools
| Tool | What it does |
+44
View File
@@ -0,0 +1,44 @@
# macOS DMG window
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
Finder scripting on CI, nothing extra installed in the signing job).
| File | What |
|---|---|
| `background.svg` | Source of the background: the app icon (`assets/app-icon/designcraft.svg`, linked, not copied) cropped as a cover on the DesignCraft colour field (`#7bb51c`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. |
| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. |
| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, DesignCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
## The volume name has no version
The mounted volume is called `DesignCraft`, not `DesignCraft <version>`. The layout points at the background
through an alias that includes the volume name, and Finder resolves it by that name: with a
versioned name the window keeps its size and icon positions but shows no background (tested).
The DMG file name (`designcraft-<version>-macos-<arch>.dmg`) still carries the version.
## Rules
- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
so the area under both icons stays light (Paper).
- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
the label strip under it.
## Regenerate
Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
```sh
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
python3 packaging/macos/dmg/generate.py # needs resvg on PATH
```
Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt
title bar; the content area is 660 × 400.
This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493).
+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="660" height="400" viewBox="0 0 660 400">
<defs><clipPath id="panel"><rect width="232" height="400"/></clipPath><pattern id="hatch" width="2.6" height="2.6" patternUnits="userSpaceOnUse" patternTransform="rotate(-38)">
<rect width="2.6" height="1.05" fill="#0b0b0c"/></pattern><filter id="grain" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7"/>
<feColorMatrix values="0 0 0 0 0.04 0 0 0 0 0.04 0 0 0 0 0.05 0.5 0 0 0 -0.225"/>
</filter></defs>
<rect width="660" height="400" fill="#efe9dc"/>
<g clip-path="url(#panel)"><rect width="232" height="400" fill="#7bb51c"/><g transform="translate(-134.0,-40.0) scale(1.0)"><image href="../../../assets/app-icon/designcraft.svg" width="512" height="512"/></g></g>
<line x1="232.5" y1="0" x2="232.5" y2="400" stroke="#0b0b0c" stroke-width="1.2"/>
<line x1="236.5" y1="0" x2="236.5" y2="400" stroke="#0b0b0c" stroke-width="0.5"/>
<line x1="267" y1="88.5" x2="624" y2="88.5" stroke="#0b0b0c" stroke-width="0.6"/>
<path d="M271.5,81.5 L276,88.5 L271.5,95.5 L267,88.5 Z" fill="#7bb51c"/>
<rect x="408" y="198" width="14" height="14" fill="#efe9dc" stroke="#0b0b0c" stroke-width="1.3"/><path d="M412,201.5 L418,205 L412,208.5 Z" fill="#7bb51c"/><line x1="424" y1="205" x2="476" y2="205" stroke="#7bb51c" stroke-width="1.6" stroke-dasharray="4 3"/><rect x="478" y="198" width="14" height="14" fill="#efe9dc" stroke="#0b0b0c" stroke-width="1.3"/><path d="M482,201.5 L488,205 L482,208.5 Z" fill="#0b0b0c"/>
<line x1="267" y1="335.5" x2="624" y2="335.5" stroke="#0b0b0c" stroke-width="0.6"/>
<g id="text">
<g>
<path fill="#0b0b0c" stroke="none" d="M 275.58008 72 L 268.19727 72 L 268.19727 50.17383 L 275.72656 50.17383 Q 278.97852 50.17383 281.31494 51.484863 Q 283.65137 52.7959 284.89648 55.234863 Q 286.1416 57.673832 286.1416 61.057617 Q 286.1416 64.4707 284.88916 66.92432 Q 283.63672 69.37793 281.271 70.688965 Q 278.90527 72 275.58008 72 Z M 272.1084 68.61621 L 275.38965 68.61621 Q 282.28906 68.61621 282.28906 61.057617 Q 282.28906 53.557617 275.4922 53.557617 L 272.1084 53.557617 Z M 296.42285 72.336914 Q 293.94727 72.336914 292.16748 71.296875 Q 290.3877 70.256836 289.42822 68.36719 Q 288.46875 66.47754 288.46875 63.914063 Q 288.46875 61.394527 289.41357 59.48291 Q 290.3584 57.571285 292.09424 56.49463 Q 293.83008 55.41797 296.17383 55.41797 Q 298.16602 55.41797 299.87256 56.28955 Q 301.5791 57.161133 302.61914 58.984863 Q 303.65918 60.80859 303.65918 63.679688 L 303.65918 64.88086 L 292.2627 64.88086 Q 292.33594 67.06348 293.48584 68.206055 Q 294.63574 69.34863 296.4668 69.34863 Q 297.72656 69.34863 298.62744 68.80664 Q 299.52832 68.26465 299.90918 67.22461 L 303.41016 67.94238 Q 302.82422 69.91992 300.98584 71.12842 Q 299.14746 72.336914 296.42285 72.336914 Z M 292.27734 62.288086 L 299.95313 62.288086 Q 299.77734 60.530273 298.83252 59.46826 Q 297.8877 58.40625 296.18848 58.40625 Q 294.4453 58.40625 293.42725 59.526855 Q 292.40918 60.64746 292.27734 62.288086 Z M 312.62207 72.336914 Q 309.79492 72.336914 307.95654 71.12842 Q 306.11816 69.91992 305.69336 67.69336 L 309.25293 67.01953 Q 309.89746 69.421875 312.6953 69.421875 Q 314.10156 69.421875 314.9292 68.85791 Q 315.75684 68.293945 315.75684 67.458984 Q 315.75684 66.06738 313.54492 65.583984 L 310.74707 64.9834 Q 306.14746 63.9873 306.14746 60.398438 Q 306.14746 58.874996 306.98242 57.76172 Q 307.81738 56.648438 309.3042 56.033203 Q 310.79102 55.41797 312.7539 55.41797 Q 315.5371 55.41797 317.15576 56.575195 Q 318.7744 57.73242 319.27246 59.63672 L 315.87402 60.310547 Q 315.58105 59.460938 314.84863 58.860348 Q 314.1162 58.25976 312.79785 58.25976 Q 311.59668 58.25976 310.77637 58.794434 Q 309.95605 59.3291 309.95605 60.16406 Q 309.95605 60.88184 310.49072 61.34326 Q 311.0254 61.804688 312.28516 62.083004 L 315.06836 62.66894 Q 319.65332 63.65039 319.65332 67.09277 Q 319.65332 68.64551 318.75977 69.82471 Q 317.8662 71.00391 316.27686 71.67041 Q 314.6875 72.336914 312.62207 72.336914 Z M 322.40527 72 L 322.40527 55.623047 L 326.21387 55.623047 L 326.21387 72 Z M 324.30957 53.308594 Q 323.40137 53.308594 322.7495 52.700684 Q 322.09766 52.092773 322.09766 51.213863 Q 322.09766 50.34961 322.7495 49.7417 Q 323.40137 49.13379 324.30957 49.13379 Q 325.23242 49.13379 325.88428 49.7417 Q 326.53613 50.34961 326.53613 51.213863 Q 326.53613 52.092773 325.88428 52.700684 Q 325.23242 53.308594 324.30957 53.308594 Z M 336.81738 78.47461 Q 333.8584 78.47461 332.0713 77.390625 Q 330.28418 76.30664 329.66895 74.680664 L 332.8916 73.52344 Q 333.27246 74.22656 334.14404 74.90039 Q 335.01563 75.57422 336.80273 75.57422 Q 338.5752 75.57422 339.64453 74.74658 Q 340.71387 73.918945 340.71387 72.16113 L 340.71387 69.07031 L 340.3916 69.07031 Q 340.09863 69.65625 339.55664 70.28613 Q 339.01465 70.916016 338.10645 71.333496 Q 337.19824 71.75098 335.77734 71.75098 Q 333.90234 71.75098 332.37158 70.864746 Q 330.84082 69.978516 329.93994 68.206055 Q 329.03906 66.43359 329.03906 63.76758 Q 329.03906 61.086914 329.93994 59.219234 Q 330.84082 57.351563 332.3789 56.38477 Q 333.917 55.41797 335.80664 55.41797 Q 337.25684 55.41797 338.19434 55.901367 Q 339.13184 56.38477 339.68848 57.065918 Q 340.24512 57.74707 340.5381 58.33301 L 340.74316 58.33301 L 340.74316 55.623047 L 344.47852 55.623047 L 344.47852 72.043945 Q 344.47852 74.24121 343.48242 75.66211 Q 342.48633 77.08301 340.7578 77.77881 Q 339.0293 78.47461 336.81738 78.47461 Z M 336.84668 68.74805 Q 338.70703 68.74805 339.71777 67.41504 Q 340.72852 66.08203 340.72852 63.73828 Q 340.72852 61.409176 339.73242 59.980957 Q 338.73633 58.552734 336.84668 58.552734 Q 334.9131 58.552734 333.92432 60.032227 Q 332.93555 61.511723 332.93555 63.73828 Q 332.93555 66.00879 333.93164 67.37842 Q 334.92773 68.74805 336.84668 68.74805 Z M 351.8301 62.405273 L 351.8301 72 L 348.02148 72 L 348.02148 55.623047 L 351.625 55.623047 L 351.66898 58.889645 Q 353.13376 55.41797 356.7373 55.41797 Q 359.27145 55.41797 360.80228 57.0293 Q 362.333 58.640625 362.333 61.58496 L 362.333 72 L 358.52438 72 L 358.52438 62.170902 Q 358.52438 60.530273 357.6748 59.592777 Q 356.82523 58.655273 355.3311 58.655273 Q 353.80762 58.655273 352.81882 59.63672 Q 351.8301 60.618164 351.8301 62.405273 Z M 375.35352 72.29297 Q 372.48242 72.29297 370.22653 70.95996 Q 367.9707 69.62695 366.667 67.12207 Q 365.36328 64.61719 365.36328 61.101563 Q 365.36328 57.571285 366.67432 55.059082 Q 367.98535 52.54688 370.2412 51.213863 Q 372.49707 49.88086 375.35352 49.88086 Q 377.74124 49.88086 379.69678 50.774414 Q 381.65234 51.66797 382.92676 53.359863 Q 384.20114 55.051754 384.5527 57.4Line truncated
</g>
<g>
<path fill="#0b0b0c" fill-opacity="0.7" stroke="none" d="M 267.81 112 L 267.81 111.262 L 269.592 111.262 L 269.592 106.131996 L 267.81 107.464 L 267.81 106.537 L 269.295 105.42999 L 270.402 105.42999 L 270.402 111.262 L 271.86 111.262 L 271.86 112 Z M 273.17398 112 L 273.17398 111.253006 L 275.361 108.913 Q 275.83798 108.409 276.03598 108.01301 Q 276.234 107.617 276.234 107.23899 Q 276.234 106.699 275.9235 106.3795 Q 275.613 106.06 275.082 106.06 Q 274.533 106.06 274.2135 106.388504 Q 273.89398 106.717 273.89398 107.275 L 273.08398 107.275 Q 273.111 106.384 273.651 105.862 Q 274.19098 105.34 275.082 105.34 Q 275.991 105.34 276.5265 105.853 Q 277.06198 106.36599 277.06198 107.24801 Q 277.06198 107.716 276.80548 108.238 Q 276.54898 108.76 275.92798 109.408 L 274.155 111.262 L 277.143 111.262 L 277.143 112 Z M 285.97202 112.09 Q 285.07202 112.09 284.532 111.586 Q 283.992 111.082 283.992 110.19999 L 283.992 108.85 Q 283.992 107.968 284.532 107.464 Q 285.07202 106.95999 285.97202 106.95999 Q 286.82703 106.95999 287.35352 107.41901 Q 287.88 107.878 287.907 108.670006 L 287.09702 108.670006 Q 287.07 108.193 286.773 107.9365 Q 286.476 107.68 285.97202 107.68 Q 285.441 107.68 285.12152 107.9815 Q 284.802 108.28299 284.802 108.841 L 284.802 110.19999 Q 284.802 110.758 285.12152 111.064 Q 285.441 111.37 285.97202 111.37 Q 286.476 111.37 286.773 111.10899 Q 287.07 110.848 287.09702 110.380005 L 287.907 110.380005 Q 287.88 111.172 287.35352 111.631004 Q 286.82703 112.09 285.97202 112.09 Z M 291.30002 112.072 Q 290.409 112.072 289.8825 111.55 Q 289.35602 111.028 289.35602 110.092 L 289.35602 108.958 Q 289.35602 108.01301 289.87802 107.4955 Q 290.4 106.978 291.30002 106.978 Q 292.2 106.978 292.72202 107.4955 Q 293.24402 108.01301 293.24402 108.958 L 293.24402 110.092 Q 293.24402 111.028 292.7175 111.55 Q 292.191 112.072 291.30002 112.072 Z M 291.30002 111.35199 Q 291.831 111.35199 292.1325 111.055 Q 292.43402 110.758 292.43402 110.182 L 292.43402 108.868 Q 292.43402 108.29201 292.1325 107.995 Q 291.831 107.698006 291.30002 107.698006 Q 290.778 107.698006 290.47202 107.995 Q 290.16602 108.29201 290.16602 108.868 L 290.16602 110.182 Q 290.16602 110.758 290.47202 111.055 Q 290.778 111.35199 291.30002 111.35199 Z M 297.42 112 Q 296.763 112 296.3715 111.61751 Q 295.98 111.235 295.98 110.605 L 295.98 106.168 L 294.27 106.168 L 294.27 105.42999 L 296.79 105.42999 L 296.79 110.605 Q 296.79 110.911 296.961 111.0865 Q 297.132 111.262 297.42 111.262 L 298.95 111.262 L 298.95 112 Z M 302.091 112.09 Q 301.236 112.09 300.723 111.581505 Q 300.21 111.07299 300.21 110.19999 L 300.21 107.05 L 301.02 107.05 L 301.02 110.19999 Q 301.02 110.74 301.30798 111.059494 Q 301.596 111.379 302.091 111.379 Q 302.595 111.379 302.88748 111.059494 Q 303.18 110.74 303.18 110.19999 L 303.18 107.05 L 303.99 107.05 L 303.99 110.19999 Q 303.99 111.07299 303.468 111.581505 Q 302.94598 112.09 302.091 112.09 Z M 305.34 112 L 305.34 107.05 L 306.042 107.05 L 306.042 107.707 L 306.06 107.707 Q 306.10498 107.374 306.3255 107.167 Q 306.546 106.95999 306.888 106.95999 Q 307.21198 106.95999 307.43698 107.149 Q 307.662 107.338 307.761 107.661995 L 307.77 107.661995 Q 307.83298 107.338 308.0535 107.149 Q 308.274 106.95999 308.616 106.95999 Q 309.093 106.95999 309.3765 107.3155 Q 309.65997 107.671 309.65997 108.238 L 309.65997 112 L 308.904 112 L 308.904 108.229004 Q 308.904 107.932 308.75998 107.7565 Q 308.616 107.581 308.36398 107.581 Q 308.121 107.581 307.977 107.752 Q 307.83298 107.923004 307.83298 108.21999 L 307.83298 112 L 307.167 112 L 307.167 108.229004 Q 307.167 107.932 307.02298 107.7565 Q 306.879 107.581 306.636 107.581 Q 306.384 107.581 306.24 107.752 Q 306.09598 107.923004 306.09598 108.21999 L 306.09598 112 Z M 311.028 112 L 311.028 107.05 L 311.838 107.05 L 311.838 107.995 L 311.84702 107.995 Q 311.91 107.5 312.252 107.23 Q 312.59402 106.95999 313.161 106.95999 Q 313.90802 106.95999 314.349 107.41901 Q 314.79 107.878 314.79 108.670006 L 314.79 112 L 313.98 112 L 313.98 108.813995 Q 313.98 108.247 313.6965 107.9455 Q 313.41302 107.644 312.927 107.644 Q 312.423 107.644 312.13052 107.959 Q 311.838 108.274 311.838 108.85 L 311.838 112 Z M 318.10202 112.072 Q 317.27402 112.072 316.81952 111.6985 Q 316.36502 111.325 316.36502 110.641 L 317.17502 110.641 Q 317.17502 110.983 317.418 111.1765 Q 317.661 111.37 318.10202 111.37 L 318.49802 111.37 Q 318.948 111.37 319.1955 111.172 Q 319.443 110.97401 319.443 110.614 Q 319.443 109.975 318.858 109.894 L 317.49002 109.696 Q 316.986 109.624 316.707 109.277504 Q 316.428 108.931 316.428 108.364 Q 316.428 107.716 316.8735 107.34699 Q 317.319 106.978 318.10202 106.978 L 318.49802 106.978 Q 319.21802 106.978 319.67252 107.333496 Q 320.127 107.689 320.163 108.265 L 319.344 108.265 Q 319.32602 108.00399 319.0965 107.83299 Q 318.867 107.661995 318.49802 107.661995 L 318.10202 107.661995 Q 317.68802 107.661995 317.454 107.851 Q 317.22 108.04 317.22 108.364 Q 317.22 108.88601 317.697 108.958 L 318.975 109.138 Q 320.23502 Line truncated
</g>
<g>
<path fill="#0b0b0c" fill-opacity="0.65" stroke="none" d="M 270.4912 358 L 267.88623 358 L 267.88623 349.99707 L 270.5664 349.99707 Q 271.74805 349.99707 272.5913 350.4778 Q 273.43457 350.9585 273.88574 351.8501 Q 274.3369 352.7417 274.3369 353.9878 Q 274.3369 355.23923 273.88306 356.13623 Q 273.4292 357.0332 272.56714 357.5166 Q 271.70508 358 270.4912 358 Z M 269.11084 356.92578 L 270.4214 356.92578 Q 271.78027 356.92578 272.45703 356.16574 Q 273.1338 355.40573 273.1338 353.9878 Q 273.1338 352.5752 272.4651 351.82327 Q 271.7964 351.07126 270.48584 351.07126 L 269.11084 351.07126 Z M 277.3247 358 L 277.3247 349.99707 L 280.19287 349.99707 Q 281.1167 349.99707 281.73438 350.31934 Q 282.35205 350.6416 282.6582 351.20825 Q 282.96436 351.77487 282.96436 352.51074 Q 282.96436 353.3164 282.59912 353.90457 Q 282.2339 354.49268 281.50342 354.7666 L 283.26514 358 L 281.87402 358 L 280.25195 354.97607 Q 280.21973 354.97607 280.18213 354.97607 L 278.54932 354.97607 L 278.54932 358 Z M 278.54932 353.9287 L 280.06396 353.9287 Q 280.94482 353.9287 281.33423 353.55542 Q 281.72363 353.18216 281.72363 352.51074 Q 281.72363 351.83398 281.33154 351.4392 Q 280.93945 351.04443 280.05322 351.04443 L 278.54932 351.04443 Z M 285.33984 358 L 288.18652 349.99707 L 289.69043 349.99707 L 292.59082 358 L 291.26416 358 L 290.52832 355.88916 L 287.38086 355.88916 L 286.6665 358 Z M 287.72998 354.86862 L 290.16846 354.86862 L 289.70654 353.54733 Q 289.5454 353.06934 289.35742 352.43823 Q 289.16943 351.80713 288.9331 350.9585 Q 288.69678 351.81784 288.51147 352.4543 Q 288.32617 353.09082 288.17578 353.54733 Z M 298.21045 358.10742 Q 297.1255 358.10742 296.3037 357.6079 Q 295.48193 357.1084 295.0254 356.1899 Q 294.56885 355.27148 294.56885 354.00388 Q 294.56885 352.72562 295.03345 351.80444 Q 295.49805 350.8833 296.30908 350.38647 Q 297.12012 349.88965 298.15674 349.88965 Q 299.02148 349.88965 299.7251 350.2253 Q 300.4287 350.56104 300.88794 351.15454 Q 301.34717 351.74805 301.48145 352.52686 L 300.2246 352.52686 Q 300.0044 351.82327 299.4807 351.41504 Q 298.95703 351.00684 298.16748 351.00684 Q 297.49072 351.00684 296.95093 351.3506 Q 296.41113 351.6943 296.09692 352.36304 Q 295.7827 353.03174 295.7827 353.99854 Q 295.7827 354.96533 296.09692 355.63135 Q 296.41113 356.29736 296.95898 356.6438 Q 297.50684 356.99023 298.21582 356.99023 Q 299.1665 356.99023 299.74927 356.42896 Q 300.33203 355.86768 300.35352 354.90625 L 298.39307 354.90625 L 298.39307 353.89648 L 301.55127 353.89648 L 301.55127 354.81494 Q 301.55127 355.8247 301.1189 356.56323 Q 300.68652 357.30173 299.93457 357.7046 Q 299.18262 358.10742 298.21045 358.10742 Z M 312.33936 358.10742 Q 311.30273 358.10742 310.48364 357.61328 Q 309.66455 357.11914 309.19458 356.20068 Q 308.7246 355.2822 308.7246 354.00388 Q 308.7246 352.7202 309.19458 351.7991 Q 309.66455 350.87793 310.48364 350.3838 Q 311.30273 349.88965 312.33936 349.88965 Q 313.37598 349.88965 314.1897 350.3838 Q 315.00342 350.87793 315.4734 351.7991 Q 315.94336 352.7202 315.94336 354.00388 Q 315.94336 355.2822 315.4734 356.20337 Q 315.00342 357.1245 314.1897 357.616 Q 313.37598 358.10742 312.33936 358.10742 Z M 312.33936 356.99023 Q 313.02148 356.99023 313.56128 356.6465 Q 314.10107 356.30273 314.41528 355.63672 Q 314.7295 354.9707 314.7295 354.00388 Q 314.7295 353.03174 314.41528 352.36304 Q 314.10107 351.6943 313.56128 351.3506 Q 313.02148 351.00684 312.33936 351.00684 Q 311.65186 351.00684 311.10938 351.3506 Q 310.5669 351.6943 310.2527 352.36304 Q 309.93848 353.03174 309.93848 354.00388 Q 309.93848 354.9707 310.2527 355.63672 Q 310.5669 356.30273 311.10938 356.6465 Q 311.65186 356.99023 312.33936 356.99023 Z M 318.93652 358 L 318.93652 349.99707 L 320.34912 349.99707 L 323.3623 354.76123 Q 323.5503 355.062 323.80273 355.51053 Q 324.05518 355.95898 324.32373 356.5337 Q 324.28076 355.9536 324.26196 355.42453 Q 324.24316 354.8955 324.24316 354.55176 L 324.24316 349.99707 L 325.4839 349.99707 L 325.4839 358 L 324.06592 358 L 321.36426 353.74072 Q 321.16553 353.4238 320.9856 353.1123 Q 320.80566 352.80078 320.59082 352.41135 Q 320.37598 352.02194 320.08594 351.48483 Q 320.12354 352.23682 320.1504 352.82764 Q 320.17725 353.41846 320.17725 353.72998 L 320.17725 358 Z M 328.35352 351.0498 L 328.35352 349.99707 L 334.56787 349.99707 L 334.56787 351.0498 L 332.08105 351.0498 L 332.08105 358 L 330.85107 358 L 330.85107 351.0498 Z M 340.43994 358.10742 Q 339.40332 358.10742 338.58423 357.61328 Q 337.76514 357.11914 337.29517 356.20068 Q 336.8252 355.2822 336.8252 354.00388 Q 336.8252 352.7202 337.29517 351.7991 Q 337.76514 350.87793 338.58423 350.3838 Q 339.40332 349.88965 340.43994 349.88965 Q 341.47656 349.88965 342.29028 350.3838 Q 343.104 350.87793 343.57397 351.7991 Q 344.0439 352.7202 344.0439 354.00388 Q 344.0439 355.2822 343.57397 356.20337 Q 343.104 357.1245 342.29028 357.616 Q 341.47656 358.10742 340.43994 358.10742 Z M 340.43994 356.99023 Q 341.12207 356.99023 341.66187 356.6465 Q 342.20166 356.30273 342.51587 35Line truncated
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.
Binary file not shown.
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here).
Needs resvg on PATH and:
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing
from the machine that made them: no local paths, user or disk names, dates or volume UUIDs.
python3 packaging/macos/dmg/generate.py
"""
import datetime
import os
import shutil
import subprocess
import sys
import tempfile
from ds_store import DSStore
from mac_alias import Alias, TargetInfo, VolumeInfo
from PIL import Image, ImageCms
HERE = os.path.dirname(os.path.abspath(__file__))
# Keep in sync with package.sh (volume name) and README.md (layout).
VOLUME = "DesignCraft"
WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size
TITLE_BAR = 32
ICON_SIZE = 128
PALETTE = 16 # colours in background.tiff
ICONS = {"DesignCraft.app": (326, 205), "Applications": (574, 205)}
# A fixed date for the alias and the colour profile. Finder never matches it against the image
# (each build is a new volume): it finds the background by volume name and path.
FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc)
def render(svg, width, out):
# The SVG's text is already outlined and no fonts are loaded, so every machine draws the
# same pixels.
subprocess.run(
["resvg", "--skip-system-fonts", "-w", str(width), svg, out],
check=True,
)
with Image.open(out) as im:
return im.convert("RGB")
def quantize(im):
# No dithering: dither noise compresses badly and the artwork has flat fills.
return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
def srgb_profile():
# LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed.
icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
d = FIXED_DATE
icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0))
if icc[84:100] != bytes(16):
sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date")
return bytes(icc)
def write_tiff(svg, out):
"""1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them."""
with tempfile.TemporaryDirectory() as tmp:
one, two = (
quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2)
)
two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page
# Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette
# keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's.
one.save(
out,
"TIFF",
save_all=True,
append_images=[two],
compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate)
dpi=(72, 72),
icc_profile=srgb_profile(),
)
with Image.open(out) as im:
im.seek(1)
if im.info.get("dpi") != (144, 144):
sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)")
strips = []
for page in (0, 1):
im.seek(page)
strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts
# TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff
# leaves the padding byte after it uninitialised, so the file changed from run to run with
# identical pixels. Zero it: nothing refers to that byte.
starts = {o for o, _ in strips}
with open(out, "r+b") as f:
for o, n in strips:
if (o + n) % 2 and o + n not in starts:
f.seek(o + n)
f.write(bytes(1))
def background_alias():
# What Finder stores for /.background/background.tiff on the volume, minus anything about the
# machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable),
# the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids
# are only hints, as the volume's creation date.
volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0")
volume.posix_path = f"/Volumes/{VOLUME}"
target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0")
target.folder_name = ".background"
target.cnid_path = [25]
target.carbon_path = f"{VOLUME}:.background:\0background.tiff"
target.posix_path = "/.background/background.tiff"
return Alias(volume=volume, target=target).to_bytes()
def write_ds_store(out):
if os.path.exists(out):
os.remove(out)
with DSStore.open(out, "w+") as d:
d["."]["bwsp"] = {
"ShowStatusBar": False,
"ShowToolbar": False,
"ShowTabView": False,
"ContainerShowSidebar": False,
"ShowSidebar": False,
"WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}",
}
d["."]["icvp"] = {
"viewOptionsVersion": 1,
"backgroundType": 2,
"backgroundImageAlias": background_alias(),
"backgroundColorRed": 1.0,
"backgroundColorGreen": 1.0,
"backgroundColorBlue": 1.0,
"gridOffsetX": 0.0,
"gridOffsetY": 0.0,
"gridSpacing": 100.0,
"arrangeBy": "none",
"showIconPreview": True,
"showItemInfo": False,
"labelOnBottom": True,
"textSize": 16.0,
"iconSize": float(ICON_SIZE),
}
d["."]["vSrn"] = ("long", 1)
for name, pos in ICONS.items():
d[name]["Iloc"] = pos
def main():
if not shutil.which("resvg"):
sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)")
write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff"))
write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store"))
if __name__ == "__main__":
main()
+6 -1
View File
@@ -131,10 +131,15 @@ STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/DesignCraft.app"
ln -s /Applications "$STAGE/Applications"
# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
mkdir -p "$STAGE/.background"
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
hdiutil makehybrid -hfs -hfs-volume-name "DesignCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
# The volume name has no version: .DS_Store finds the background through an alias that includes it.
hdiutil makehybrid -hfs -hfs-volume-name "DesignCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"