From a11bd021cd1f96195d2db6bd626e65e2a7d87fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 8 Oct 2026 23:32:53 +0200 Subject: [PATCH 001/128] feat(pptx): export documents as editable PowerPoint presentations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New designcraft-pptx crate (L3) and file.exportPptx command (File › Export PowerPoint…, CLI --export x.pptx). Each page becomes a slide of native objects: shapes with fills, gradients, strokes and effects, pictures cropped like their frames, native tables, and text boxes. Text keeps DesignCraft's composition by default: composed lines end in line breaks, justified spacing becomes character spacing, and leading becomes percentage line spacing (PowerPoint rounds exact spacing to whole points; its 100% line is 1.2 em for every font). Threaded stories split at their frames and wrapped lines get their own boxes. lineBreaks: reflow writes one box per frame in native columns instead. TrueType fonts are embedded as Embedded OpenType when their licences allow. Objects PowerPoint can't represent (placed PDF/EPS, type on a path, some effects) are rasterised through the existing rasterize_where path. Checked in PowerPoint for Mac and LibreOffice: on the sample magazine, word positions match DesignCraft's PDF to within 0.8 pt horizontally and 1.6 pt vertically. --- Cargo.lock | 18 + Cargo.toml | 1 + README.md | 2 +- ROADMAP.md | 1 + apps/designcraft-cli/src/main.rs | 12 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/export.rs | 117 ++++- crates/pptx/Cargo.toml | 24 + crates/pptx/src/fonts.rs | 220 +++++++++ crates/pptx/src/lib.rs | 130 ++++++ crates/pptx/src/package.rs | 335 ++++++++++++++ crates/pptx/src/slide.rs | 749 +++++++++++++++++++++++++++++++ crates/pptx/src/table.rs | 208 +++++++++ crates/pptx/src/tests.rs | 271 +++++++++++ crates/pptx/src/text.rs | 625 ++++++++++++++++++++++++++ crates/pptx/src/xml.rs | 75 ++++ crates/ui-egui/src/menus.rs | 3 + xtask/src/layers.rs | 1 + 18 files changed, 2782 insertions(+), 11 deletions(-) create mode 100644 crates/pptx/Cargo.toml create mode 100644 crates/pptx/src/fonts.rs create mode 100644 crates/pptx/src/lib.rs create mode 100644 crates/pptx/src/package.rs create mode 100644 crates/pptx/src/slide.rs create mode 100644 crates/pptx/src/table.rs create mode 100644 crates/pptx/src/tests.rs create mode 100644 crates/pptx/src/text.rs create mode 100644 crates/pptx/src/xml.rs diff --git a/Cargo.lock b/Cargo.lock index 671341623..d7e987313 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,6 +939,7 @@ dependencies = [ "designcraft-idml", "designcraft-images", "designcraft-pdf", + "designcraft-pptx", "designcraft-render", "designcraft-textimport", "designcraft-tools", @@ -1058,6 +1059,23 @@ dependencies = [ "tiff", ] +[[package]] +name = "designcraft-pptx" +version = "0.4.0" +dependencies = [ + "designcraft-color", + "designcraft-compose", + "designcraft-doc", + "designcraft-fonts", + "designcraft-geom", + "designcraft-images", + "image", + "quick-xml 0.38.4", + "skrifa 0.47.0", + "thiserror 2.0.21", + "zip", +] + [[package]] name = "designcraft-render" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index c59188532..3910f9572 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ designcraft-render = { path = "crates/render" } designcraft-pdf = { path = "crates/pdf" } designcraft-idml = { path = "crates/idml" } designcraft-epub = { path = "crates/epub" } +designcraft-pptx = { path = "crates/pptx" } designcraft-textimport = { path = "crates/textimport" } designcraft-format = { path = "crates/format" } designcraft-tools = { path = "crates/tools" } diff --git a/README.md b/README.md index 41af2b453..cf73543ee 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ 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, PNG and editable PowerPoint export, and PDF on the roadmap. 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 diff --git a/ROADMAP.md b/ROADMAP.md index aa4c17396..07bbe4cdc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,6 +49,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign. - Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts. - File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB). +- File ▸ Export PowerPoint (`file.exportPptx`, CLI `--export x.pptx`): every page becomes a slide of editable objects: shapes (preset or custom geometry, solid and gradient fills, dashed/compound strokes with arrowheads, shadows, glow, feather, opacity), pictures cropped like their frames (SVG kept as SVG), native tables (merged cells, fills, per-edge borders) and text boxes. By default the text keeps DesignCraft's line breaks, justified spacing and leading; threaded stories split at their frames and lines pushed aside by a wrap get boxes of their own. `lineBreaks: reflow` writes one box per frame in native columns instead. TrueType fonts are embedded where their licences allow, and objects without a PowerPoint equivalent are rasterised. Checked against PowerPoint for Mac and LibreOffice. - Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup). - Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place). - Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions. diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 9e9eba25d..ce760cd48 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -1,7 +1,7 @@ //! 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] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub|.pptx] [--pdf-options JSON] [--all-pages DIR] //! 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] @@ -209,6 +209,14 @@ fn run(args: &[String]) -> Result<(), String> { } fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> { + if out.to_ascii_lowercase().ends_with(".pptx") { + let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]); + for w in r["warnings"].as_array().into_iter().flatten() { + eprintln!("warning: {}", w.as_str().unwrap_or_default()); + } + return Ok(()); + } if out.to_ascii_lowercase().ends_with(".idml") { let r = s.execute("file.exportIdml", &json!({"path": out})).map_err(|e| e.to_string())?; eprintln!("wrote {out} ({} bytes)", r["bytes"]); @@ -332,6 +340,8 @@ fn script(args: &[String]) -> Result<(), String> { exec(b, "file.exportIdml", p)?; } else if lower.ends_with(".epub") { exec(b, "file.exportEpub", p)?; + } else if lower.ends_with(".pptx") { + exec(b, "file.exportPptx", p)?; } else { // PNG / JPEG of the first page. b.call("app.export", p)?; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ff02d62b8..93e7ae3b2 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -18,6 +18,7 @@ designcraft-compose = { workspace = true } designcraft-render = { workspace = true } designcraft-format = { workspace = true } designcraft-epub = { workspace = true } +designcraft-pptx = { workspace = true } designcraft-textimport = { workspace = true } designcraft-pdf = { workspace = true } designcraft-tools = { workspace = true } diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index bf986ced7..9a928dbc1 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -25,6 +25,9 @@ pub fn specs() -> Vec { cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None, "{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes} (no path: {text, bytes})", has_doc, export_html), + cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None, + "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})", + has_doc, export_pptx), cmd!(noundo "file.exportText", "Export Text…", ["File"], None, "{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})", has_story_target, export_text), @@ -66,6 +69,43 @@ fn export_html(s: &mut Session, p: &Value) -> Result { } } +fn export_pptx(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.exportPptx"; + let st = s.doc()?; + let line_breaks = match str_param(p, "lineBreaks") { + None | Some("keep") => designcraft_pptx::LineBreaks::Keep, + Some("reflow") => designcraft_pptx::LineBreaks::Reflow, + Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))), + }; + let opts = designcraft_pptx::PptxOptions { + pages: pages_param(ID, p, st.doc.page_count())?, + line_breaks, + title: str_param(p, "title").map(str::to_string), + author: str_param(p, "author").map(str::to_string), + created: None, + embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true), + }; + // Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame + // stays text: a picture of it would drop the rest of its story). + let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true); + let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, |it| { + !threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it))) + }); + let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); + let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + if rasterized > 0 { + r.warnings.push(format!("{rasterized} object(s) went in as images")); + } + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})) + } + None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})), + } +} + fn has_story_target(s: &Session) -> std::result::Result<(), String> { super::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into()) } @@ -167,26 +207,31 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { } } -pub(crate) fn options(p: &Value, page_count: usize) -> Result { - const ID: &str = "file.exportPdf"; - - let pages = match p.get("pages") { +/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions. +fn pages_param(id: &str, p: &Value, page_count: usize) -> Result>> { + Ok(match p.get("pages") { None | Some(Value::Null) => None, - Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(ID, e.to_string()))?), + Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(id, e.to_string()))?), Some(Value::Array(a)) => { let mut v = Vec::new(); for x in a { - let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {x}")))?; + let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {x}")))?; v.push(n as usize - 1); } Some(v) } Some(Value::Number(n)) => { - let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {n}")))?; + let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {n}")))?; Some(vec![n as usize - 1]) } - Some(v) => return Err(bad(ID, format!("bad `pages`: {v}"))), - }; + Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))), + }) +} + +pub(crate) fn options(p: &Value, page_count: usize) -> Result { + const ID: &str = "file.exportPdf"; + + let pages = pages_param(ID, p, page_count)?; let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false); let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false); let marks = match p.get("marks") { @@ -899,3 +944,57 @@ mod print_group_tests { assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes())); } } + +#[cfg(test)] +mod pptx_tests { + use serde_json::json; + + use crate::Session; + + fn unzip(b64: &str) -> zip::ZipArchive>> { + zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() + } + + fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); + s + } + + #[test] + fn sample_exports_editable_slides() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let r = s.execute("file.exportPptx", &json!({})).unwrap(); + assert_eq!(r["slides"], 4); + let mut z = unzip(r["base64"].as_str().unwrap()); + // The swatch table is a PowerPoint table; the cover picture is embedded; text is text. + assert!(read(&mut z, "ppt/slides/slide4.xml").contains("")); + assert!(read(&mut z, "ppt/slides/slide1.xml").contains("")); + let text: String = read(&mut z, "ppt/slides/slide2.xml").split("").skip(1).filter_map(|t| t.split("").next()).collect(); + assert!(text.contains("Notes on the Grid"), "{text}"); + assert!(read(&mut z, "ppt/presentation.xml").contains("")); + + let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap(); + assert_eq!(r["slides"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded"))); + assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err()); + assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err()); + } + + #[test] + fn rasterize_option_places_a_picture() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); + let shapes = |s: &mut Session| { + let r = s.execute("file.exportPptx", &json!({})).unwrap(); + read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml") + }; + assert!(shapes(&mut s).contains("prst=\"ellipse\"")); + s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap(); + let x = shapes(&mut s); + assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}"); + } +} diff --git a/crates/pptx/Cargo.toml b/crates/pptx/Cargo.toml new file mode 100644 index 000000000..fc8fbac71 --- /dev/null +++ b/crates/pptx/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "designcraft-pptx" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DesignCraft PowerPoint (PPTX) export: pages as slides of editable shapes, pictures and text" + +[lints] +workspace = true + +[dependencies] +designcraft-geom = { workspace = true } +designcraft-color = { workspace = true } +designcraft-doc = { workspace = true } +designcraft-fonts = { workspace = true } +designcraft-compose = { workspace = true } +designcraft-images = { workspace = true } +skrifa = { workspace = true } +image = { workspace = true } +thiserror = { workspace = true } +zip = { workspace = true } + +[dev-dependencies] +quick-xml = { workspace = true } diff --git a/crates/pptx/src/fonts.rs b/crates/pptx/src/fonts.rs new file mode 100644 index 000000000..4dceb6ea6 --- /dev/null +++ b/crates/pptx/src/fonts.rs @@ -0,0 +1,220 @@ +//! Font names as PowerPoint looks them up, and nominal advances to measure its line widths. + +use std::collections::HashMap; + +use designcraft_fonts::FontFace; +use skrifa::MetadataProvider; +use skrifa::string::StringId; + +/// How a run names its face: the legacy (style-linked) family plus bold and italic flags, which +/// is how Office finds a face (`Source Serif 4 Semibold` is its own family; `Bold` is a flag). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RunFont { + pub family: String, + pub bold: bool, + pub italic: bool, +} + +#[derive(Default)] +pub struct Fonts { + names: HashMap, + cmaps: HashMap>, + /// Glyph → the lowest character mapping to it. + reverse: HashMap>, + /// Faces the slides use, by (family, bold, italic), in first-use order. + pub used: Vec<(RunFont, designcraft_fonts::FaceRef)>, +} + +fn english(face: &FontFace, id: StringId) -> Option { + let f = face.skrifa()?; + let mut first = None; + for s in f.localized_strings(id) { + let text: String = s.chars().collect(); + if text.trim().is_empty() { + continue; + } + if s.language().is_none_or(|l| l.starts_with("en")) { + return Some(text); + } + first.get_or_insert(text); + } + first +} + +impl Fonts { + /// The run font for a face a slide uses (recorded for embedding). + pub fn use_face(&mut self, face: designcraft_fonts::FaceRef) -> RunFont { + let rf = self.run_font(face.get()); + if !self.used.iter().any(|(u, _)| *u == rf) { + self.used.push((rf.clone(), face)); + } + rf + } + + /// Embedded OpenType for every family used, and the faces that couldn't be embedded. + pub fn embedded(&self) -> (Vec, Vec) { + let mut out: Vec = Vec::new(); + let mut skipped = Vec::new(); + for (rf, face) in &self.used { + let face = face.get(); + match eot(face) { + Ok(bytes) => { + let slot = usize::from(rf.bold) + 2 * usize::from(rf.italic); + let at = match out.iter().position(|e| e.family == rf.family) { + Some(i) => i, + None => { + out.push(EmbeddedFont { family: rf.family.clone(), ..Default::default() }); + out.len() - 1 + } + }; + if let Some(e) = out.get_mut(at) + && let Some(s) = e.faces.get_mut(slot) + { + *s = Some(bytes); + } + } + Err(why) => skipped.push(format!( + "{} {} ({})", + face.family, + face.style, + if why == NoEmbed::Restricted { "its licence forbids embedding" } else { "not a TrueType font" } + )), + } + } + (out, skipped) + } + + pub fn run_font(&mut self, face: &FontFace) -> RunFont { + self.names + .entry(face.id()) + .or_insert_with(|| { + let family = english(face, StringId::FAMILY_NAME).unwrap_or_else(|| face.family.clone()); + let sub = english(face, StringId::SUBFAMILY_NAME).unwrap_or_else(|| face.style.clone()).to_ascii_lowercase(); + // A named instance of a variable font has no legacy names of its own. + if face.is_variable() { + let style = face.style.to_ascii_lowercase(); + let bold = face.weight >= 650.0; + let regular = matches!(style.as_str(), "regular" | "italic" | "bold" | "bold italic"); + let family = if regular { face.family.clone() } else { format!("{} {}", face.family, face.style.replace(" Italic", "")) }; + return RunFont { family, bold: bold && regular, italic: face.italic }; + } + RunFont { family, bold: sub.contains("bold"), italic: sub.contains("italic") || sub.contains("oblique") } + }) + .clone() + } + + /// Nominal advance of `c` in font units (no kerning or features), as an application without + /// the composer's spacing would set it. + pub fn advance(&mut self, face: &FontFace, c: char) -> Option { + self.cmap(face).get(&c).map(|g| face.advance(*g)) + } + + pub fn glyph(&mut self, face: &FontFace, c: char) -> Option { + self.cmap(face).get(&c).copied() + } + + /// The character a glyph shows (the lowest code point mapping to it). + pub fn char_of(&mut self, face: &FontFace, gid: u32) -> Option { + let map = self.reverse.entry(face.id()).or_insert_with(|| { + let mut m = HashMap::new(); + for (c, g) in face.chars() { + m.entry(g).or_insert(c); + } + m + }); + map.get(&gid).copied() + } + + fn cmap(&mut self, face: &FontFace) -> &HashMap { + self.cmaps.entry(face.id()).or_insert_with(|| face.chars().into_iter().collect()) + } +} + +/// A family embedded in the presentation: its regular, bold, italic and bold italic faces. +#[derive(Clone, Debug, Default)] +pub struct EmbeddedFont { + pub family: String, + pub faces: [Option>; 4], +} + +fn be16(d: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes([*d.get(at)?, *d.get(at + 1)?])) +} + +fn be32(d: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes([*d.get(at)?, *d.get(at + 1)?, *d.get(at + 2)?, *d.get(at + 3)?])) +} + +fn utf16_name(out: &mut Vec, s: &str) { + let units: Vec = s.encode_utf16().collect(); + let bytes = (units.len() * 2).min(u16::MAX as usize & !1); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&(bytes as u16).to_le_bytes()); + for u in units.iter().take(bytes / 2) { + out.extend_from_slice(&u.to_le_bytes()); + } +} + +/// Why a face can't be embedded. +#[derive(Debug, PartialEq, Eq)] +pub enum NoEmbed { + /// The font's licence (OS/2 `fsType`) forbids embedding. + Restricted, + /// PowerPoint embeds TrueType outlines only (not CFF, collections or variable instances). + Format, +} + +/// The face as Embedded OpenType (uncompressed, version 2.1), the form PowerPoint stores +/// embedded fonts in. +pub fn eot(face: &FontFace) -> Result, NoEmbed> { + use skrifa::raw::TableProvider as _; + use skrifa::raw::types::Tag; + let data = face.data(); + if face.index() != 0 || face.is_variable() || data.get(..4) == Some(b"OTTO") || data.get(..4) == Some(b"ttcf") { + return Err(NoEmbed::Format); + } + let f = face.skrifa().ok_or(NoEmbed::Format)?; + if f.glyf().is_err() { + return Err(NoEmbed::Format); + } + let os2 = f.table_data(Tag::new(b"OS/2")).map(|t| t.as_bytes().to_vec()).unwrap_or_default(); + let head = f.table_data(Tag::new(b"head")).map(|t| t.as_bytes().to_vec()).unwrap_or_default(); + let fs_type = be16(&os2, 8).unwrap_or(0); + // Restricted License embedding (bit 1 alone): may not be embedded. + if fs_type & 0x000F == 0x0002 { + return Err(NoEmbed::Restricted); + } + let mut h: Vec = Vec::with_capacity(256); + h.extend_from_slice(&[0; 8]); // EOTSize, FontDataSize (filled in below) + h.extend_from_slice(&0x0002_0001u32.to_le_bytes()); + h.extend_from_slice(&0u32.to_le_bytes()); // Flags: no compression, no obfuscation + h.extend_from_slice(os2.get(32..42).unwrap_or(&[0; 10])); + h.push(1); // DEFAULT_CHARSET + h.push(u8::from(face.italic)); + h.extend_from_slice(&u32::from(be16(&os2, 4).unwrap_or(400)).to_le_bytes()); + h.extend_from_slice(&fs_type.to_le_bytes()); + h.extend_from_slice(&0x504Cu16.to_le_bytes()); + for i in 0..4 { + h.extend_from_slice(&be32(&os2, 42 + 4 * i).unwrap_or(0).to_le_bytes()); + } + let has_codepages = be16(&os2, 0).unwrap_or(0) >= 1; + for i in 0..2 { + h.extend_from_slice(&(if has_codepages { be32(&os2, 78 + 4 * i).unwrap_or(0) } else { 0 }).to_le_bytes()); + } + h.extend_from_slice(&be32(&head, 8).unwrap_or(0).to_le_bytes()); + h.extend_from_slice(&[0; 16]); // Reserved1–4 + let family = english(face, StringId::FAMILY_NAME).unwrap_or_else(|| face.family.clone()); + let style = english(face, StringId::SUBFAMILY_NAME).unwrap_or_else(|| face.style.clone()); + let version = english(face, StringId::VERSION_STRING).unwrap_or_default(); + let full = english(face, StringId::FULL_NAME).unwrap_or_else(|| format!("{family} {style}")); + utf16_name(&mut h, &family); + utf16_name(&mut h, &style); + utf16_name(&mut h, &version); + utf16_name(&mut h, &full); + utf16_name(&mut h, ""); // RootString + let total = h.len().checked_add(data.len()).and_then(|n| u32::try_from(n).ok()).ok_or(NoEmbed::Format)?; + h[0..4].copy_from_slice(&total.to_le_bytes()); + h[4..8].copy_from_slice(&(data.len() as u32).to_le_bytes()); + h.extend_from_slice(data); + Ok(h) +} diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs new file mode 100644 index 000000000..c62bb707e --- /dev/null +++ b/crates/pptx/src/lib.rs @@ -0,0 +1,130 @@ +//! PowerPoint export (File › Export › PowerPoint). +//! +//! Every exported page becomes a slide of native, editable objects: frames become shapes (preset +//! rectangles and ellipses, custom geometry otherwise) with their fills, strokes and effects, +//! placed images become pictures cropped like their frames, and text frames become text boxes. +//! +//! Text keeps DesignCraft's composition by default ([`LineBreaks::Keep`]): each composed line ends +//! in a line break, justified word spacing and tracking become character spacing, and leading +//! becomes percentage line spacing (PowerPoint rounds exact point spacing to whole points; its +//! 100% line is 1.2 em for every font, so a percentage is exact). A threaded story is split at its +//! frames, and the lines a text wrap pushes aside become boxes of their own, since PowerPoint has +//! neither threads nor wraps. [`LineBreaks::Reflow`] instead writes one box per frame with native +//! columns and lets PowerPoint break the lines. +//! +//! Parent-page items are copied onto each slide; layers are flattened in stacking order. What has +//! no PowerPoint equivalent (blend modes, type on a path, some effects) is approximated or +//! dropped and reported in [`ExportReport::warnings`]; [`needs_raster`] names the objects a caller +//! should rasterise first. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod fonts; +mod package; +mod slide; +mod table; +mod text; +mod xml; + +#[cfg(test)] +mod tests; + +use designcraft_compose::Cache; +use designcraft_doc::Document; + +pub use slide::needs_raster; + +/// How text frames become text boxes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LineBreaks { + /// Keep DesignCraft's line breaks, word spacing and leading (looks the same; edits don't + /// re-break lines). + #[default] + Keep, + /// One box per frame with native columns; PowerPoint breaks the lines (edits reflow; lines, + /// hyphenation and wraps differ). + Reflow, +} + +#[derive(Clone, Debug, Default)] +pub struct PptxOptions { + /// Document pages to export (0-based positions); all when `None`. + pub pages: Option>, + pub line_breaks: LineBreaks, + pub title: Option, + pub author: Option, + /// Creation time (Unix seconds) for the document properties; none when `None`. + pub created: Option, + /// Embed the TrueType fonts the text uses (when their licences allow it), so the slides + /// look the same where they aren't installed. + pub embed_fonts: bool, +} + +/// The exported presentation and what could not be represented exactly. +#[derive(Clone, Debug, Default)] +pub struct ExportReport { + pub bytes: Vec, + pub slides: usize, + pub warnings: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum PptxError { + #[error("the document has no pages to export")] + NoPages, + #[error("page {0} does not exist")] + BadPage(usize), + #[error("could not write the presentation: {0}")] + Write(String), +} + +pub type Result = std::result::Result; + +/// Export `doc` as a PowerPoint presentation. +pub fn export_pptx(doc: &Document, cache: &Cache, opts: &PptxOptions) -> Result { + let count = doc.page_count(); + let pages: Vec = match &opts.pages { + Some(v) => v.clone(), + None => (0..count).collect(), + }; + if pages.is_empty() { + return Err(PptxError::NoPages); + } + if let Some(bad) = pages.iter().find(|p| **p >= count) { + return Err(PptxError::BadPage(bad + 1)); + } + let mut ex = slide::Exporter::new(doc, cache, opts); + let mut slides = Vec::with_capacity(pages.len()); + for abs in &pages { + slides.push(ex.slide(*abs)?); + } + let size = slides.first().map(|s| s.size).ok_or(PptxError::NoPages)?; + if slides.iter().any(|s| (s.size.0 - size.0).abs() > 0.5 || (s.size.1 - size.1).abs() > 0.5) { + ex.warn("pages of different sizes: PowerPoint has one slide size, so every slide uses the first page's"); + } + let fonts = if opts.embed_fonts { + let (fonts, skipped) = ex.fonts.embedded(); + if !skipped.is_empty() { + ex.warn(format!("fonts not embedded: {}", skipped.join(", "))); + } + fonts + } else { + let mut names: Vec = ex.fonts.used.iter().map(|(f, _)| f.family.clone()).collect(); + names.dedup(); + if !names.is_empty() { + ex.warn(format!("fonts are not embedded; the slides need {} installed to look the same", names.join(", "))); + } + Vec::new() + }; + let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); + let bytes = package::write(&package::Package { + size, + slides: &slides, + media: &ex.media, + fonts: &fonts, + title: &title, + author: opts.author.as_deref().unwrap_or(""), + created: opts.created, + })?; + Ok(ExportReport { bytes, slides: slides.len(), warnings: ex.warnings }) +} diff --git a/crates/pptx/src/package.rs b/crates/pptx/src/package.rs new file mode 100644 index 000000000..ed5ad553b --- /dev/null +++ b/crates/pptx/src/package.rs @@ -0,0 +1,335 @@ +//! The PresentationML package: one blank master and layout, a theme, the slides and their media. + +use std::fmt::Write as _; +use std::io::Write as _; + +use crate::fonts::EmbeddedFont; +use crate::xml::{emu_len, esc}; +use crate::{PptxError, Result}; + +pub const NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#; +const DECL: &str = "\n"; +const REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; +pub const REL_IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; + +/// A relationship from a slide to another part. +#[derive(Clone, Debug)] +pub struct Rel { + pub id: String, + pub kind: &'static str, + pub target: String, +} + +/// One slide: its shape tree content (everything inside `` after the group +/// properties) and relationships beyond the layout (`rId1`). +#[derive(Clone, Debug, Default)] +pub struct Slide { + /// Page size in points. + pub size: (f64, f64), + pub shapes: String, + pub rels: Vec, + pub name: String, +} + +/// A media part (`ppt/media/`). +#[derive(Clone, Debug)] +pub struct Media { + pub name: String, + pub bytes: Vec, +} + +pub struct Package<'a> { + pub size: (f64, f64), + pub slides: &'a [Slide], + pub media: &'a [Media], + pub fonts: &'a [EmbeddedFont], + pub title: &'a str, + pub author: &'a str, + pub created: Option, +} + +fn rels(list: &[(String, &str, String)]) -> String { + let mut s = format!("{DECL}"); + for (id, kind, target) in list { + let _ = write!(s, "", esc(target)); + } + s.push_str(""); + s +} + +/// PowerPoint accepts slide sizes from 1 inch to 56 inches. +fn slide_extent(pt: f64) -> i64 { + emu_len(pt).clamp(914_400, 51_206_400) +} + +pub fn write(p: &Package) -> Result> { + let mut parts: Vec<(String, Vec)> = Vec::new(); + let mut put = |name: &str, s: String| parts.push((name.to_string(), s.into_bytes())); + let n = p.slides.len(); + + // Content types. + let mut ct = format!( + "{DECL}\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +\ +" + ); + for i in 1..=n { + let _ = write!( + ct, + "" + ); + } + ct.push_str(""); + put("[Content_Types].xml", ct); + + put( + "_rels/.rels", + rels(&[ + ("rId1".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "ppt/presentation.xml".into()), + ("rId2".into(), "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", "docProps/core.xml".into()), + ("rId3".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", "docProps/app.xml".into()), + ]), + ); + + // Presentation: master rId1, slides rId2.., then the property parts. + let (cx, cy) = (slide_extent(p.size.0), slide_extent(p.size.1)); + let mut ids = String::new(); + let mut prels: Vec<(String, &str, String)> = vec![( + "rId1".into(), + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster", + "slideMasters/slideMaster1.xml".into(), + )]; + for i in 1..=n { + let _ = write!(ids, "", 255 + i, i + 1); + prels.push(( + format!("rId{}", i + 1), + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide", + format!("slides/slide{i}.xml"), + )); + } + for (k, (kind, target)) in + [("presProps", "presProps.xml"), ("viewProps", "viewProps.xml"), ("theme", "theme/theme1.xml"), ("tableStyles", "tableStyles.xml")] + .iter() + .enumerate() + { + prels.push((format!("rId{}", n + 2 + k), rel_kind(kind), target.to_string())); + } + // Embedded fonts: one part per face, listed per family in regular, bold, italic, bold italic. + let mut font_list = String::new(); + let mut font_parts: Vec<(String, &[u8])> = Vec::new(); + for f in p.fonts { + let _ = write!(font_list, "", esc(&f.family)); + for (slot, tag) in ["regular", "bold", "italic", "boldItalic"].iter().enumerate() { + if let Some(Some(bytes)) = f.faces.get(slot) { + let id = format!("rId{}", prels.len() + 1); + let target = format!("fonts/font{}.fntdata", font_parts.len() + 1); + let _ = write!(font_list, ""); + prels.push((id, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font", target.clone())); + font_parts.push((format!("ppt/{target}"), bytes.as_slice())); + } + } + font_list.push_str(""); + } + let (embed_attr, font_list) = if font_parts.is_empty() { + ("", String::new()) + } else { + (" embedTrueTypeFonts=\"1\"", format!("{font_list}")) + }; + put( + "ppt/presentation.xml", + format!( + "{DECL}\ +{ids}{font_list}\ +" + ), + ); + put("ppt/_rels/presentation.xml.rels", rels(&prels)); + + put("ppt/presProps.xml", format!("{DECL}")); + put( + "ppt/viewProps.xml", + format!( + "{DECL}\ +\ +" + ), + ); + put( + "ppt/tableStyles.xml", + format!( + "{DECL}" + ), + ); + + // One blank master and layout. + let tree = ""; + put( + "ppt/slideMasters/slideMaster1.xml", + format!( + "{DECL}{tree}\ +\ +\ +" + ), + ); + put( + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + rels(&[ + ( + "rId1".into(), + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout", + "../slideLayouts/slideLayout1.xml".into(), + ), + ("rId2".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "../theme/theme1.xml".into()), + ]), + ); + put( + "ppt/slideLayouts/slideLayout1.xml", + format!( + "{DECL}{tree}" + ), + ); + put( + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + rels(&[( + "rId1".into(), + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster", + "../slideMasters/slideMaster1.xml".into(), + )]), + ); + put("ppt/theme/theme1.xml", theme()); + + for (i, s) in p.slides.iter().enumerate() { + let name = if s.name.is_empty() { String::new() } else { format!(" name=\"{}\"", esc(&s.name)) }; + put( + &format!("ppt/slides/slide{}.xml", i + 1), + format!( + "{DECL}\ +{}\ +", + s.shapes + ), + ); + let mut list: Vec<(String, &str, String)> = vec![( + "rId1".into(), + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout", + "../slideLayouts/slideLayout1.xml".into(), + )]; + list.extend(s.rels.iter().map(|r| (r.id.clone(), r.kind, r.target.clone()))); + put(&format!("ppt/slides/_rels/slide{}.xml.rels", i + 1), rels(&list)); + } + + put("docProps/core.xml", core(p)); + put( + "docProps/app.xml", + format!( + "{DECL}DesignCraft{n}Custom" + ), + ); + + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let zerr = |e: zip::result::ZipError| PptxError::Write(e.to_string()); + let ioerr = |e: std::io::Error| PptxError::Write(e.to_string()); + for (name, bytes) in &parts { + zw.start_file(name.as_str(), deflate).map_err(zerr)?; + zw.write_all(bytes).map_err(ioerr)?; + } + for (name, bytes) in &font_parts { + zw.start_file(name.as_str(), deflate).map_err(zerr)?; + zw.write_all(bytes).map_err(ioerr)?; + } + for m in p.media { + // Compressed formats gain nothing from deflate. + let opts = if m.name.ends_with(".svg") { deflate } else { stored }; + zw.start_file(format!("ppt/media/{}", m.name), opts).map_err(zerr)?; + zw.write_all(&m.bytes).map_err(ioerr)?; + } + Ok(zw.finish().map_err(zerr)?.into_inner()) +} + +fn rel_kind(k: &str) -> &'static str { + match k { + "presProps" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps", + "viewProps" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/viewProps", + "theme" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", + _ => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles", + } +} + +fn core(p: &Package) -> String { + let mut s = format!( + "{DECL}" + ); + if !p.title.is_empty() { + let _ = write!(s, "{}", esc(p.title)); + } + if !p.author.is_empty() { + let _ = write!(s, "{}", esc(p.author)); + } + if let Some(t) = p.created { + let (y, mo, d, h, mi, se) = civil(t); + let _ = write!(s, "{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z"); + } + s.push_str(""); + s +} + +/// Unix seconds → UTC civil date and time (proleptic Gregorian). +fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) { + let days = t.div_euclid(86_400); + let secs = t.rem_euclid(86_400); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = yoe + era * 400 + i64::from(m <= 2); + (y.clamp(0, 9999), m, d, (secs / 3600) as u32, (secs / 60 % 60) as u32, (secs % 60) as u32) +} + +/// A neutral theme: text sets its own fonts and colours, so these only matter for new objects. +fn theme() -> String { + let fill3 = "".repeat(3); + let ln = "".repeat(3); + let fx = "".repeat(3); + format!( + "{DECL}\ +\ +\ +\ +\ +{fill3}{ln}{fx}{fill3}\ +" + ) +} + +#[cfg(test)] +mod tests { + use super::civil; + + #[test] + fn civil_dates() { + assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0)); + assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0)); + assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3)); + } +} diff --git a/crates/pptx/src/slide.rs b/crates/pptx/src/slide.rs new file mode 100644 index 000000000..f4cf4386f --- /dev/null +++ b/crates/pptx/src/slide.rs @@ -0,0 +1,749 @@ +//! A page as a slide: items in stacking order as shapes, pictures, groups and text boxes. + +use std::collections::HashMap; +use std::fmt::Write as _; + +use designcraft_color::swatch::resolve_gradient; +use designcraft_color::{BlendMode, GradientKind}; +use designcraft_compose::Cache; +use designcraft_doc::{Arrowhead, AssetId, Cap, Content, Document, Graphic, Item, Join, Shape, Stroke, StrokeAlign, StrokeType}; +use designcraft_geom::kurbo::{PathEl, Shape as _}; +use designcraft_geom::{Affine, BezPath, Point, Rect, Vec2, corners}; + +use crate::fonts::Fonts; +use crate::package::{Media, REL_IMAGE, Rel, Slide}; +use crate::xml::{angle, emu, emu_len, esc, hex, pct}; +use crate::{PptxError, PptxOptions, Result}; + +/// Objects PowerPoint can't show as editable objects, which a caller should rasterise before +/// exporting: placed PDF and EPS graphics, type on a path, and effects without a DrawingML +/// counterpart. +pub fn needs_raster(doc: &Document, it: &Item) -> bool { + let fx = &it.effects; + let effects = fx.inner_glow.on || fx.bevel.on || fx.satin.on || fx.directional_feather.on || fx.gradient_feather.on; + let placed = |g: &Graphic| { + doc.assets.get(&g.asset).is_some_and(|a| { + let m = a.mime.as_str(); + m == "application/pdf" || m.contains("postscript") || m.contains("illustrator") + }) + }; + let on_path = it.text_frame().is_some_and(|t| t.options.path.is_some()); + effects || on_path || it.graphic().is_some_and(placed) || it.children().iter().any(|c| needs_raster(doc, c)) +} + +/// A DrawingML transform box (slide space, points). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct XfrmBox { + pub rect: Rect, + pub rot: f64, + pub flip_h: bool, + pub flip_v: bool, +} + +impl XfrmBox { + pub fn plain(rect: Rect) -> Self { + XfrmBox { rect, rot: 0.0, flip_h: false, flip_v: false } + } + + pub fn xml(&self) -> String { + let mut a = String::new(); + let r = angle(self.rot); + if r != 0 { + let _ = write!(a, " rot=\"{r}\""); + } + if self.flip_h { + a.push_str(" flipH=\"1\""); + } + if self.flip_v { + a.push_str(" flipV=\"1\""); + } + format!( + "", + emu(self.rect.x0), + emu(self.rect.y0), + emu_len(self.rect.width()), + emu_len(self.rect.height()) + ) + } +} + +/// An affine split into rotation, axis scales and shear: `m = translate · rotate(rot) · +/// [sx, shear·sy; 0, sy]`. +#[derive(Clone, Copy, Debug)] +pub struct Decomposed { + pub rot: f64, + pub sx: f64, + pub sy: f64, + pub shear: f64, +} + +pub fn decompose(m: Affine) -> Decomposed { + let [a, b, c, d, _, _] = m.as_coeffs(); + let sx = a.hypot(b); + let rot = b.atan2(a); + let (cos, sin) = (rot.cos(), rot.sin()); + let c2 = cos * c + sin * d; + let d2 = -sin * c + cos * d; + let shear = if d2.abs() > 1e-12 { c2 / d2 } else { 0.0 }; + Decomposed { rot: rot.to_degrees(), sx, sy: d2, shear } +} + +/// The box `inner` occupies under `m` when `m` has no shear: rotated about its centre, flipped +/// vertically for a negative determinant. `None` for sheared transforms. +pub fn box_of(inner: Rect, m: Affine) -> Option<(XfrmBox, Decomposed)> { + let dm = decompose(m); + if dm.shear.abs() > 1e-6 || !dm.sx.is_finite() || !dm.sy.is_finite() { + return None; + } + let c = m * inner.center(); + let (w, h) = (inner.width() * dm.sx.abs(), inner.height() * dm.sy.abs()); + let rect = Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0); + Some((XfrmBox { rect, rot: dm.rot, flip_h: false, flip_v: dm.sy < 0.0 }, dm)) +} + +/// Shape geometry: a preset, or a custom path in the box's own (unrotated) space. +pub struct Geometry { + pub xfrm: XfrmBox, + pub xml: String, +} + +fn custom_geometry(bp: &BezPath, w: f64, h: f64, filled: bool) -> String { + let (we, he) = (emu_len(w).max(1), emu_len(h).max(1)); + let mut p = format!( + "", + if filled { "" } else { " fill=\"none\"" } + ); + let pt = |q: Point| format!("", emu(q.x), emu(q.y)); + let mut last = Point::ZERO; + for el in bp.elements() { + match *el { + PathEl::MoveTo(a) => { + let _ = write!(p, "{}", pt(a)); + last = a; + } + PathEl::LineTo(a) => { + let _ = write!(p, "{}", pt(a)); + last = a; + } + PathEl::QuadTo(a, b) => { + // As a cubic: PowerPoint's quadratic support is patchy. + let c1 = last + (a - last) * (2.0 / 3.0); + let c2 = b + (a - b) * (2.0 / 3.0); + let _ = write!(p, "{}{}{}", pt(c1), pt(c2), pt(b)); + last = b; + } + PathEl::CurveTo(a, b, c) => { + let _ = write!(p, "{}{}{}", pt(a), pt(b), pt(c)); + last = c; + } + PathEl::ClosePath => p.push_str(""), + } + } + p.push_str(""); + p +} + +fn preset(name: &str) -> String { + format!("") +} + +/// Is `bp` exactly the rectangle `r` (any start corner and direction)? +fn is_rect(bp: &BezPath, r: Rect) -> bool { + let mut pts = Vec::new(); + for el in bp.elements() { + match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p), + PathEl::ClosePath => {} + _ => return false, + } + } + if pts.len() == 5 && pts.first().zip(pts.last()).is_some_and(|(a, b)| (*a - *b).hypot() < 1e-6) { + pts.pop(); + } + pts.len() == 4 + && pts.iter().all(|p| ((p.x - r.x0).abs() < 1e-6 || (p.x - r.x1).abs() < 1e-6) && ((p.y - r.y0).abs() < 1e-6 || (p.y - r.y1).abs() < 1e-6)) +} + +/// The geometry of `it` (its path, corner options applied) under `m` (inner → slide). +pub fn geometry(it: &Item, m: Affine) -> Geometry { + let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) }; + let closed = it.path.is_closed(); + let inner = bp.bounding_box(); + if let Some((xfrm, dm)) = box_of(inner, m) { + if it.corners.is_none() && is_rect(&bp, inner) { + return Geometry { xfrm, xml: preset("rect") }; + } + if it.shape == Shape::Oval && it.corners.is_none() && closed { + return Geometry { xfrm, xml: preset("ellipse") }; + } + // The path in the box's own space: scaled (not flipped: the box flips), from its corner. + let (kx, ky) = (dm.sx.abs(), dm.sy.abs()); + let mut local = bp.clone(); + local.apply_affine(Affine::scale_non_uniform(kx, ky) * Affine::translate(-inner.origin().to_vec2())); + return Geometry { xfrm, xml: custom_geometry(&local, xfrm.rect.width(), xfrm.rect.height(), closed) }; + } + // Sheared: the transformed path in slide space. + let mut pb = bp; + pb.apply_affine(m); + let b = pb.bounding_box(); + pb.apply_affine(Affine::translate(-b.origin().to_vec2())); + Geometry { xfrm: XfrmBox::plain(b), xml: custom_geometry(&pb, b.width(), b.height(), closed) } +} + +pub struct Exporter<'a> { + pub doc: &'a Document, + pub cache: &'a Cache, + pub opts: &'a PptxOptions, + pub warnings: Vec, + pub media: Vec, + /// Asset → (picture media name, SVG media name). + media_of: HashMap)>>, + pub fonts: Fonts, + // Per slide. + pub out: String, + next_id: u32, + rels: Vec, + rel_of: HashMap, + /// Spread space → slide space. + pub to_slide: Affine, + /// The page's bleed area in spread space (items outside it are left out). + clip: Rect, + pub page_name: Option, +} + +impl<'a> Exporter<'a> { + pub fn new(doc: &'a Document, cache: &'a Cache, opts: &'a PptxOptions) -> Self { + Exporter { + doc, + cache, + opts, + warnings: Vec::new(), + media: Vec::new(), + media_of: HashMap::new(), + fonts: Fonts::default(), + out: String::new(), + next_id: 2, + rels: Vec::new(), + rel_of: HashMap::new(), + to_slide: Affine::IDENTITY, + clip: Rect::ZERO, + page_name: None, + } + } + + pub fn warn(&mut self, w: impl Into) { + let w = w.into(); + if !self.warnings.contains(&w) { + self.warnings.push(w); + } + } + + pub fn id(&mut self) -> u32 { + let i = self.next_id; + self.next_id = self.next_id.saturating_add(1); + i + } + + pub fn slide(&mut self, abs: usize) -> Result { + let doc = self.doc; + let (si, pi) = doc.page_loc(abs).ok_or(PptxError::BadPage(abs + 1))?; + let sp = doc.spreads.get(si).ok_or(PptxError::BadPage(abs + 1))?; + let page = sp.pages.get(pi).ok_or(PptxError::BadPage(abs + 1))?; + let bounds = page.bounds(); + self.out.clear(); + self.next_id = 2; + self.rels.clear(); + self.rel_of.clear(); + self.to_slide = Affine::translate(-bounds.origin().to_vec2()); + let b = doc.settings.bleed; + self.clip = Rect::new(bounds.x0 - b[2].max(b[3]), bounds.y0 - b[0], bounds.x1 + b[2].max(b[3]), bounds.y1 + b[1]); + let page_name = doc.page_name(abs); + for layer in doc.layers.iter().rev() { + if !layer.visible || !layer.printable { + continue; + } + if page.show_parent_items + && let Some((ppi, ppage)) = doc.parent_page_for(abs) + && let Some(parent) = doc.parents.get(ppi) + && let Some(pp) = parent.pages.get(ppage) + { + let dx = page.x - pp.x; + for it in &parent.items { + if it.layer != layer.id || page.overridden.contains(&it.id) { + continue; + } + if parent.pages.len() > 1 && parent.page_at_x(it.bounds().center().x) != Some(ppage) { + continue; + } + // Parent items compose per page (page numbers, running heads). + self.page_name = Some(page_name.clone()); + self.item(it, Affine::translate((dx, 0.0)), 1.0); + self.page_name = None; + } + } + for it in &sp.items { + if it.layer == layer.id { + self.item(it, Affine::IDENTITY, 1.0); + } + } + } + Ok(Slide { + size: (bounds.width(), bounds.height()), + shapes: std::mem::take(&mut self.out), + rels: std::mem::take(&mut self.rels), + name: page_name, + }) + } + + fn rel(&mut self, target: String) -> String { + if let Some(r) = self.rel_of.get(&target) { + return r.clone(); + } + let id = format!("rId{}", self.rels.len() + 2); + self.rels.push(Rel { id: id.clone(), kind: REL_IMAGE, target: target.clone() }); + self.rel_of.insert(target, id.clone()); + id + } + + /// One item under `parent` (inner-of-parent → spread), with an inherited opacity. + pub fn item(&mut self, it: &Item, parent: Affine, opacity: f32) { + if it.hidden || it.nonprinting { + return; + } + let reach = it.stroke.extent() + + 2.0 + + if it.effects.drop_shadow.on { it.effects.drop_shadow.distance.abs() + it.effects.drop_shadow.size.abs() } else { 0.0 }; + let vb = parent.transform_rect_bbox(it.bounds()).inflate(reach, reach); + if vb.x1 < self.clip.x0 || vb.x0 > self.clip.x1 || vb.y1 < self.clip.y0 || vb.y0 > self.clip.y1 { + return; + } + if it.blend != BlendMode::Normal { + self.warn("blend modes have no PowerPoint equivalent; those objects are drawn normally"); + } + if needs_raster(self.doc, it) && !it.is_group() { + self.warn("some effects (inner glow, bevel and emboss, satin, gradient and directional feather), type on a path and placed PDF/EPS graphics can't be exported as PowerPoint objects; rasterise them (Object Export Options) or they are approximated"); + } + let xf = parent * it.xf; + let opacity = opacity * it.opacity.clamp(0.0, 1.0); + if it.has_nested_items() { + // Paste Into: the frame, then its contents (PowerPoint can't clip a group to a shape). + self.shape(it, xf, opacity, None); + let fb = xf.transform_rect_bbox(it.inner_bounds()); + for c in it.children() { + let cb = xf.transform_rect_bbox(c.bounds()); + if cb.x0 < fb.x0 - 0.5 || cb.y0 < fb.y0 - 0.5 || cb.x1 > fb.x1 + 0.5 || cb.y1 > fb.y1 + 0.5 { + self.warn("contents pasted into a frame are not clipped to it"); + } + self.item(c, xf, opacity); + } + return; + } + if !it.children().is_empty() { + self.group(it, xf, opacity); + return; + } + match &it.content { + Content::Graphic(g) => self.picture(it, g, xf, opacity), + Content::Text(t) => { + let t = t.clone(); + crate::text::frame(self, it, &t, xf, opacity); + } + _ => self.shape(it, xf, opacity, None), + } + } + + fn group(&mut self, it: &Item, xf: Affine, opacity: f32) { + let mut body = std::mem::take(&mut self.out); + let id = self.id(); + for c in it.shown_children() { + self.item(c, xf, opacity); + } + let inner = std::mem::take(&mut self.out); + if inner.is_empty() { + self.out = body; + return; + } + // Children are in slide space: the group's child space is the slide's. + let inner_bounds = it.shown_children().map(|c| c.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + let b = (self.to_slide * xf).transform_rect_bbox(inner_bounds); + let (x, y, w, h) = (emu(b.x0), emu(b.y0), emu_len(b.width()), emu_len(b.height())); + let _ = write!( + body, + "{inner}", + esc(&name_of(it, id)), + descr(it) + ); + self.out = body; + } + + /// A shape with the item's geometry, fill, stroke and effects; `text` is a ``. + pub fn shape(&mut self, it: &Item, xf: Affine, opacity: f32, text: Option<&str>) { + let m = self.to_slide * xf; + let g = geometry(it, m); + let fill = self.fill(&it.fill, it.path.is_closed(), opacity); + let line = self.line(&it.stroke, opacity); + let fx = self.effects(it, opacity); + if text.is_none() && fill == "" && line.contains("") && fx.is_empty() { + return; + } + let id = self.id(); + let txbox = if text.is_some() && it.fill.is_none() && it.stroke.is_none() { " txBox=\"1\"" } else { "" }; + let _ = write!( + self.out, + "{}{}{fill}{line}{fx}{}", + esc(&name_of(it, id)), + descr(it), + g.xfrm.xml(), + g.xml, + text.unwrap_or("") + ); + } + + /// An sRGB colour element for a swatch and tint, `None` for [None] and unknown swatches. + pub fn color(&self, swatch: &str, tint: f32, alpha: f32) -> Option { + if let Some(g) = resolve_gradient(&self.doc.swatches, swatch) { + // A gradient used where only a colour fits (text): its first stop. + let (_, c, a) = g.expanded_stops().into_iter().next()?; + return Some(srgb(c.to_rgb(), a * alpha)); + } + let c = self.doc.resolve_color(swatch, tint)?; + Some(srgb(c.to_rgb(), alpha)) + } + + pub fn fill(&mut self, f: &designcraft_doc::Fill, closed: bool, opacity: f32) -> String { + if f.is_none() || !closed { + return "".into(); + } + if let Some(g) = resolve_gradient(&self.doc.swatches, &f.swatch) { + let mut gs = String::new(); + for (o, c, a) in g.expanded_stops() { + let _ = write!(gs, "{}", pct(o.clamp(0.0, 1.0) as f64), srgb(c.to_rgb(), a * opacity)); + } + if gs.is_empty() { + return "".into(); + } + if f.gradient_vector.is_some() { + self.warn("gradients set with the Gradient tool use their angle only; PowerPoint stretches them over the whole shape"); + } + let shade = match g.kind { + GradientKind::Radial => { + "".to_string() + } + _ => { + let deg = match f.gradient_vector { + Some([x0, y0, x1, y1]) => (y1 - y0).atan2(x1 - x0).to_degrees(), + None => -f.gradient_angle.unwrap_or(0.0), + }; + format!("", angle(deg)) + } + }; + return format!("{gs}{shade}"); + } + match self.color(&f.swatch, f.tint, opacity) { + Some(c) => format!("{c}"), + None => "".into(), + } + } + + pub fn line(&mut self, s: &Stroke, opacity: f32) -> String { + if s.is_none() || s.weight <= 0.0 { + return "".into(); + } + let Some(color) = self.color(&s.swatch, s.tint, opacity) else { return "".into() }; + let cap = match s.cap { + Cap::Butt => "flat", + Cap::Round => "rnd", + Cap::Projecting => "sq", + }; + let cmpd = match s.kind { + StrokeType::ThickThin => "thickThin", + StrokeType::ThinThick => "thinThick", + StrokeType::ThinThin | StrokeType::ThickThick => "dbl", + StrokeType::ThinThickThin | StrokeType::ThickThinThick => "tri", + _ => "sng", + }; + if matches!(s.kind, StrokeType::Wavy | StrokeType::Hashed | StrokeType::Stripes { .. } | StrokeType::Style { .. }) { + self.warn("wavy, hashed, striped and custom stroke styles are exported as solid lines"); + } + let algn = match s.align { + StrokeAlign::Inside => " algn=\"in\"", + StrokeAlign::Outside => { + self.warn("strokes aligned outside are centred on the path (PowerPoint has centre and inside only)"); + "" + } + StrokeAlign::Center => "", + }; + let w = s.weight; + let dash = match &s.kind { + StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => { + let mut v: Vec = pattern.iter().map(|x| x.max(0.0)).collect(); + if v.len() % 2 == 1 { + v.extend(v.clone()); + } + let mut d = String::from(""); + for pair in v.chunks(2) { + if let [a, b] = pair { + let _ = write!(d, "", pct(a / w), pct(b / w)); + } + } + d.push_str(""); + d + } + StrokeType::Dotted => "".into(), + _ => "".into(), + }; + let join = match s.join { + Join::Miter => format!("", pct(s.miter_limit.max(1.0))), + Join::Round => "".into(), + Join::Bevel => "".into(), + }; + let ends = |tag: &str, a: Arrowhead, me: &mut Self| -> String { + let kind = match a { + Arrowhead::None => return String::new(), + Arrowhead::Simple | Arrowhead::SimpleWide => "arrow", + Arrowhead::Triangle | Arrowhead::TriangleWide | Arrowhead::Curved => "triangle", + Arrowhead::Barbed => "stealth", + Arrowhead::Circle | Arrowhead::CircleSolid => "oval", + Arrowhead::Square | Arrowhead::SquareSolid => "diamond", + Arrowhead::Bar => { + me.warn("bar arrowheads have no PowerPoint equivalent and are left out"); + return String::new(); + } + }; + // PowerPoint's large heads come closest to DesignCraft's at the same weight. + format!("") + }; + let head = ends("headEnd", s.start, self); + let tail = ends("tailEnd", s.end, self); + format!("{color}{dash}{join}{head}{tail}", emu_len(w)) + } + + /// The item's effects that DrawingML has (in schema order). + pub fn effects(&mut self, it: &Item, opacity: f32) -> String { + let fx = &it.effects; + let mut s = String::new(); + if fx.outer_glow.on + && let Some(c) = self.color(&fx.outer_glow.color, 1.0, fx.outer_glow.opacity * opacity) + { + let _ = write!(s, "{c}", emu_len(fx.outer_glow.size)); + } + if fx.inner_shadow.on { + let sh = &fx.inner_shadow; + if let Some(c) = self.color(&sh.color, 1.0, sh.opacity * opacity) { + let a = self.doc.light_angle(sh.angle, sh.global_light); + let _ = write!( + s, + "{c}", + emu_len(sh.size), + emu_len(sh.distance.abs()), + shadow_dir(a) + ); + } + } + if fx.drop_shadow.on { + let sh = &fx.drop_shadow; + if let Some(c) = self.color(&sh.color, 1.0, sh.opacity * opacity) { + let a = self.doc.light_angle(sh.angle, sh.global_light); + let _ = write!( + s, + "{c}", + emu_len(sh.size), + emu_len(sh.distance.abs()), + shadow_dir(a) + ); + } + } + if fx.feather > 0.0 { + let _ = write!(s, "", emu_len(fx.feather)); + } + if s.is_empty() { s } else { format!("{s}") } + } + + /// The media for a placed graphic: PNG, JPEG and GIF as they are, SVG with a PNG fallback, + /// other rasters converted to PNG. + fn media_for(&mut self, asset: AssetId) -> Option<(String, Option)> { + if let Some(m) = self.media_of.get(&asset) { + return m.clone(); + } + let r = self.convert(asset); + self.media_of.insert(asset, r.clone()); + r + } + + fn convert(&mut self, asset: AssetId) -> Option<(String, Option)> { + let a = self.doc.assets.get(&asset)?.clone(); + let data: &[u8] = &a.data; + if data.is_empty() { + self.warn(format!("`{}` is missing (linked file not found); left out", a.name)); + return None; + } + let n = self.media.len() + 1; + let mime = designcraft_images::mime(data); + let ext = match mime { + "image/png" => Some("png"), + "image/jpeg" => Some("jpeg"), + "image/gif" => Some("gif"), + _ => None, + }; + if let Some(ext) = ext { + let name = format!("image{n}.{ext}"); + self.media.push(Media { name: name.clone(), bytes: data.to_vec() }); + return Some((name, None)); + } + if mime == "image/svg+xml" { + let png = designcraft_images::render_svg(data, 2048).and_then(|(px, w, h)| encode_png(unpremultiply(px), w, h)); + let Some(png) = png else { + self.warn(format!("`{}` could not be read; left out", a.name)); + return None; + }; + let name = format!("image{n}.png"); + let svg = format!("image{}.svg", n + 1); + self.media.push(Media { name: name.clone(), bytes: png }); + self.media.push(Media { name: svg.clone(), bytes: data.to_vec() }); + return Some((name, Some(svg))); + } + if mime == "application/pdf" || mime == "application/octet-stream" { + self.warn(format!( + "`{}` (PDF, EPS or an unknown format) can't be placed in PowerPoint; rasterise it (Object Export Options) to include it", + a.name + )); + return None; + } + let Some(img) = designcraft_images::decode_rgba(data) else { + self.warn(format!("`{}` could not be read; left out", a.name)); + return None; + }; + let (w, h) = img.dimensions(); + let png = encode_png(img.into_raw(), w, h)?; + let name = format!("image{n}.png"); + self.media.push(Media { name: name.clone(), bytes: png }); + Some((name, None)) + } + + fn picture(&mut self, it: &Item, g: &Graphic, xf: Affine, opacity: f32) { + let g = if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() }; + let Some((name, svg)) = self.media_for(g.asset) else { + self.shape(it, xf, opacity, None); + return; + }; + let m = self.to_slide * xf; + let [a, b, c, d, _, _] = g.xf.as_coeffs(); + let frame = it.inner_bounds(); + let img = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)); + let axis = b.abs() < 1e-9 && c.abs() < 1e-9 && a > 0.0 && d > 0.0; + let (geo, crop) = if axis && img.width() > 0.0 && img.height() > 0.0 { + // The frame's geometry with the image cropped (or padded) to the frame's box. + let crop = format!( + "", + pct((frame.x0 - img.x0) / img.width()), + pct((frame.y0 - img.y0) / img.height()), + pct((img.x1 - frame.x1) / img.width()), + pct((img.y1 - frame.y1) / img.height()) + ); + (geometry(it, m), crop) + } else { + // Rotated, skewed or flipped inside its frame: the image's own box, uncropped, and + // the frame drawn on its own. + self.warn("images rotated, skewed or flipped inside their frames are not cropped to the frame"); + self.shape(it, xf, opacity, None); + let gi = Rect::new(0.0, 0.0, g.size.0, g.size.1); + let gm = m * g.xf; + let xfrm = box_of(gi, gm).map(|(x, _)| x).unwrap_or_else(|| XfrmBox::plain(gm.transform_rect_bbox(gi))); + (Geometry { xfrm, xml: preset("rect") }, String::new()) + }; + let rid = self.rel(format!("../media/{name}")); + let svg_ext = match svg { + Some(s) => { + let sid = self.rel(format!("../media/{s}")); + format!( + "" + ) + } + None => String::new(), + }; + let alpha = if opacity < 0.999 { format!("", pct(opacity as f64)) } else { String::new() }; + let fill = self.fill(&it.fill, it.path.is_closed(), opacity); + let line = if axis { self.line(&it.stroke, opacity) } else { "".into() }; + let fx = self.effects(it, opacity); + let id = self.id(); + let _ = write!( + self.out, + "\ +{alpha}{svg_ext}{crop}\ +{}{}{fill}{line}{fx}", + esc(&name_of(it, id)), + descr(it), + geo.xfrm.xml(), + geo.xml + ); + } + + /// Paragraph shading and rules, table cell fills: a plain filled rectangle in slide space. + pub fn rect_shape(&mut self, xfrm: XfrmBox, fill: &str, name: &str) { + let id = self.id(); + let _ = write!( + self.out, + "{}{}{fill}", + esc(name), + xfrm.xml(), + preset("rect") + ); + } +} + +/// DrawingML shadow direction (clockwise from +x, y down) for a light angle (degrees, +/// counter-clockwise; the shadow falls away from the light). +fn shadow_dir(light: f64) -> i64 { + let a = light.to_radians(); + let off = Vec2::new(-a.cos(), a.sin()); + angle(off.y.atan2(off.x).to_degrees()) +} + +pub fn srgb(rgb: [f32; 3], alpha: f32) -> String { + let a = alpha.clamp(0.0, 1.0); + if a >= 0.999 { + format!("", hex(rgb)) + } else { + format!("", hex(rgb), pct(a as f64)) + } +} + +fn name_of(it: &Item, id: u32) -> String { + if !it.name.is_empty() { + return it.name.clone(); + } + let kind = match (&it.content, it.shape) { + (Content::Group { .. }, _) => "Group", + (Content::Text(_), _) => "Text Frame", + (Content::Graphic(_), _) => "Picture", + (_, Shape::Oval) => "Ellipse", + (_, Shape::Polygon) => "Polygon", + (_, Shape::GraphicLine) => "Line", + (_, Shape::Path) => "Path", + _ => "Rectangle", + }; + format!("{kind} {id}") +} + +fn descr(it: &Item) -> String { + if it.alt_text.is_empty() { String::new() } else { format!(" descr=\"{}\"", esc(&it.alt_text)) } +} + +fn unpremultiply(mut px: Vec) -> Vec { + for p in px.as_chunks_mut::<4>().0 { + let a = p[3] as u32; + if a > 0 && a < 255 { + for c in &mut p[..3] { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + px +} + +fn encode_png(rgba: Vec, w: u32, h: u32) -> Option> { + let img = image::RgbaImage::from_raw(w, h, rgba)?; + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).ok()?; + Some(out.into_inner()) +} diff --git a/crates/pptx/src/table.rs b/crates/pptx/src/table.rs new file mode 100644 index 000000000..0c8bd27d7 --- /dev/null +++ b/crates/pptx/src/table.rs @@ -0,0 +1,208 @@ +//! Table fragments as native PowerPoint tables: the grid from the cells' edges, merged cells as +//! spans, fills, edges as cell borders, and each cell's composed text. + +use std::fmt::Write as _; + +use designcraft_compose::{FrameText, PlacedCell, StrokeSeg, TableFrag}; +use designcraft_doc::StrokeType; +use designcraft_geom::{Affine, Rect}; + +use crate::slide::{Exporter, box_of}; +use crate::text::{paragraphs, seg_rect, segments}; +use crate::xml::{emu, emu_len}; + +/// Sorted distinct values (within 0.25 pt). +fn edges(mut v: Vec) -> Vec { + v.retain(|x| x.is_finite()); + v.sort_by(f64::total_cmp); + v.dedup_by(|a, b| (*a - *b).abs() < 0.25); + v +} + +fn index(edges: &[f64], x: f64) -> usize { + edges.iter().enumerate().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(0, |(i, _)| i) +} + +pub fn tables(ex: &mut Exporter, ft: &FrameText, m: Affine, k: f64, opacity: f32) { + for t in &ft.tables { + table(ex, t, m, k, opacity); + } +} + +/// The stroke along one side of a cell (`a`–`b`), if the segments on that line cover most of it +/// (a merged cell's side is drawn in pieces, one per grid cell). +fn edge_stroke(strokes: &[StrokeSeg], a: (f64, f64), b: (f64, f64)) -> Option<&StrokeSeg> { + let vertical = (a.0 - b.0).abs() < 0.25; + let (lo, hi) = if vertical { (a.1, b.1) } else { (a.0, b.0) }; + let mut covered = 0.0; + let mut first = None; + for s in strokes { + let (on, s0, s1) = if vertical { + ((s.a.x - a.0).abs() < 0.75 && (s.b.x - a.0).abs() < 0.75, s.a.y.min(s.b.y), s.a.y.max(s.b.y)) + } else { + ((s.a.y - a.1).abs() < 0.75 && (s.b.y - a.1).abs() < 0.75, s.a.x.min(s.b.x), s.a.x.max(s.b.x)) + }; + let overlap = s1.min(hi) - s0.max(lo); + if on && overlap > 0.0 { + covered += overlap; + first.get_or_insert(s); + } + } + first.filter(|_| covered > 0.5 * (hi - lo)) +} + +fn border(ex: &mut Exporter, tag: &str, s: Option<&StrokeSeg>, k: f64, opacity: f32) -> String { + let Some(s) = s.filter(|s| s.stroke.weight > 0.0) else { return format!("") }; + let st = &s.stroke; + let Some(c) = ex.color(&st.color, st.tint, opacity) else { return format!("") }; + let dash = match st.kind { + StrokeType::Dashed { .. } => "dash", + StrokeType::Dotted => "sysDot", + _ => "solid", + }; + format!( + "{c}", + emu_len(st.weight * k) + ) +} + +fn table(ex: &mut Exporter, t: &TableFrag, m: Affine, k: f64, opacity: f32) { + if t.cells.is_empty() { + return; + } + let Some((xfrm, dm)) = box_of(t.rect, m) else { return }; + if dm.rot.abs() > 0.01 || xfrm.flip_v { + ex.warn("tables in rotated or flipped frames are exported upright"); + } + let xs = edges(t.cells.iter().flat_map(|c| [c.rect.x0, c.rect.x1]).collect()); + let ys = edges(t.cells.iter().flat_map(|c| [c.rect.y0, c.rect.y1]).collect()); + if xs.len() < 2 || ys.len() < 2 { + return; + } + let (nc, nr) = (xs.len() - 1, ys.len() - 1); + // Which placed cell starts at each grid position, and which positions merged cells cover. + let mut origin: Vec> = vec![None; nc * nr]; + let mut covered: Vec<(bool, bool)> = vec![(false, false); nc * nr]; + let mut span: Vec<(usize, usize)> = vec![(1, 1); nc * nr]; + for c in &t.cells { + let (c0, c1) = (index(&xs, c.rect.x0), index(&xs, c.rect.x1)); + let (r0, r1) = (index(&ys, c.rect.y0), index(&ys, c.rect.y1)); + if c1 <= c0 || r1 <= r0 { + continue; + } + let Some(slot) = origin.get_mut(r0 * nc + c0) else { continue }; + *slot = Some(c); + if let Some(sp) = span.get_mut(r0 * nc + c0) { + *sp = (c1 - c0, r1 - r0); + } + for r in r0..r1 { + for col in c0..c1 { + if (r, col) != (r0, c0) + && let Some(cv) = covered.get_mut(r * nc + col) + { + *cv = (col > c0, r > r0); + } + } + } + } + if t.cells.iter().any(|c| c.graphic.is_some()) { + ex.warn("graphic cells are exported empty"); + } + let id = ex.id(); + let mut s = format!( + "\ +", + emu(xfrm.rect.x0), + emu(xfrm.rect.y0), + emu_len(xfrm.rect.width()), + emu_len(xfrm.rect.height()) + ); + for w in xs.windows(2) { + let _ = write!(s, "", emu_len((w[1] - w[0]) * k)); + } + s.push_str(""); + for r in 0..nr { + let h = ys.get(r + 1).zip(ys.get(r)).map_or(0.0, |(b, a)| b - a); + let _ = write!(s, "", emu_len(h * k)); + for col in 0..nc { + let at = r * nc + col; + let (hm, vm) = covered.get(at).copied().unwrap_or((false, false)); + if hm || vm { + let _ = write!( + s, + "", + if hm { " hMerge=\"1\"" } else { "" }, + if vm { " vMerge=\"1\"" } else { "" } + ); + continue; + } + let (gs, rs) = span.get(at).copied().unwrap_or((1, 1)); + let mut attrs = String::new(); + if gs > 1 { + let _ = write!(attrs, " gridSpan=\"{gs}\""); + } + if rs > 1 { + let _ = write!(attrs, " rowSpan=\"{rs}\""); + } + let Some(c) = origin.get(at).copied().flatten() else { + let _ = write!( + s, + "" + ); + continue; + }; + s.push_str(&cell(ex, c, &attrs, t, k, opacity)); + } + s.push_str(""); + } + s.push_str(""); + ex.out.push_str(&s); +} + +fn cell(ex: &mut Exporter, c: &PlacedCell, attrs: &str, t: &TableFrag, k: f64, opacity: f32) -> String { + let rc = c.rect; + let mut body = String::new(); + let mut margins = [0.0f64; 4]; + if let Some(cft) = c.text.frames.first() { + let segs = segments(ex, &c.text, cft, &c.source, &[]); + // A cell holds one box: its lines, from the first segment's slot. + let mut all = segs.first().cloned().unwrap_or_default(); + for sg in segs.iter().skip(1) { + all.lines.extend(sg.lines.iter().cloned()); + all.x1 = all.x1.max(sg.x1); + } + if !all.lines.is_empty() { + let r = seg_rect(&all); + let r = Rect::new(r.x0 + c.origin.x, r.y0 + c.origin.y, r.x1 + c.origin.x, r.y1 + c.origin.y); + margins = [(r.x0 - rc.x0).max(0.0), (r.y0 - rc.y0).max(0.0), (rc.x1 - r.x1).max(0.0), (rc.y1 - r.y1).max(0.0)]; + let local = Rect::new(r.x0 - c.origin.x, r.y0 - c.origin.y, r.x1 - c.origin.x, r.y1 - c.origin.y); + body = paragraphs(ex, &all, local, k, opacity); + } + } + if body.is_empty() { + body = "".into(); + } + let [l, tp, r, b] = margins.map(|v| emu_len(v * k)); + let ln = |side: char| -> ((f64, f64), (f64, f64)) { + match side { + 'L' => ((rc.x0, rc.y0), (rc.x0, rc.y1)), + 'R' => ((rc.x1, rc.y0), (rc.x1, rc.y1)), + 'T' => ((rc.x0, rc.y0), (rc.x1, rc.y0)), + _ => ((rc.x0, rc.y1), (rc.x1, rc.y1)), + } + }; + let mut pr = format!(""); + for (tag, side) in [("lnL", 'L'), ("lnR", 'R'), ("lnT", 'T'), ("lnB", 'B')] { + let (a, bb) = ln(side); + let seg = edge_stroke(&t.strokes, a, bb); + pr.push_str(&border(ex, tag, seg, k, opacity)); + } + match c.fill.as_ref().and_then(|(sw, tint)| ex.color(sw, *tint, opacity)) { + Some(col) => { + let _ = write!(pr, "{col}"); + } + None => pr.push_str(""), + } + pr.push_str(""); + format!("{body}{pr}") +} diff --git a/crates/pptx/src/tests.rs b/crates/pptx/src/tests.rs new file mode 100644 index 000000000..0ee9a61ce --- /dev/null +++ b/crates/pptx/src/tests.rs @@ -0,0 +1,271 @@ +//! Package structure and object mapping tests. + +use std::io::Read; +use std::sync::Arc; + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{ + Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Stroke, StrokeType, TextFrameOptions, +}; +use designcraft_geom::{Affine, Rect, shapes}; + +use crate::{LineBreaks, PptxError, PptxOptions, export_pptx}; + +fn unzip(bytes: &[u8]) -> zip::ZipArchive>> { + zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap() +} + +fn part(z: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + z.by_name(name).unwrap_or_else(|_| panic!("missing {name}")).read_to_string(&mut s).unwrap(); + s +} + +fn export(d: &Document, opts: &PptxOptions) -> zip::ZipArchive>> { + unzip(&export_pptx(d, &Cache::new(), opts).unwrap().bytes) +} + +fn rect_item(d: &mut Document, r: Rect, shape: Shape) -> ItemId { + let id = ItemId(d.alloc()); + let path = if shape == Shape::Oval { shapes::ellipse(r) } else { shapes::rectangle(r) }; + let mut it = Item::new(id, d.default_layer(), shape, path); + it.fill = Fill::swatch("[Black]"); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + id +} + +fn edit(d: &mut Document, id: ItemId, f: impl FnOnce(&mut Item)) { + let it = d.spreads.iter_mut().flat_map(|sp| Arc::make_mut(sp).items.iter_mut()).find(|it| it.id == id).unwrap(); + f(Arc::make_mut(it)); +} + +/// The text of each shape on a slide (runs joined). +fn shape_texts(slide: &str) -> Vec { + slide + .split("") + .skip(1) + .map(|sp| sp.split("").skip(1).filter_map(|t| t.split("").next()).collect::()) + .filter(|t| !t.is_empty()) + .collect() +} + +/// Every XML part parses. +fn well_formed(z: &mut zip::ZipArchive>>) { + let names: Vec = z.file_names().map(str::to_string).collect(); + for n in names.iter().filter(|n| n.ends_with(".xml") || n.ends_with(".rels")) { + let x = part(z, n); + let mut r = quick_xml::Reader::from_str(&x); + loop { + match r.read_event() { + Ok(quick_xml::events::Event::Eof) => break, + Ok(_) => {} + Err(e) => panic!("{n}: {e}"), + } + } + } +} + +#[test] +fn package_has_the_parts_powerpoint_needs() { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello & ", ParaFormat::default()).unwrap(); + let r = export_pptx(&d, &Cache::new(), &PptxOptions::default()).unwrap(); + assert_eq!(r.slides, 2); + let mut z = unzip(&r.bytes); + for p in [ + "[Content_Types].xml", + "_rels/.rels", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/theme/theme1.xml", + "ppt/slides/slide1.xml", + "ppt/slides/slide2.xml", + "ppt/slides/_rels/slide2.xml.rels", + "docProps/core.xml", + ] { + assert!(z.by_name(p).is_ok(), "{p}"); + } + well_formed(&mut z); + let pres = part(&mut z, "ppt/presentation.xml"); + let (w, h) = (d.settings.page_width, d.settings.page_height); + assert!(pres.contains(&format!("", (w * 12_700.0).round() as i64, (h * 12_700.0).round() as i64)), "{pres}"); + let s1 = part(&mut z, "ppt/slides/slide1.xml"); + assert_eq!(shape_texts(&s1), ["Hello & <world>"]); +} + +#[test] +fn shapes_keep_geometry_fill_stroke_and_effects() { + let mut d = Document::new(&NewDocument::default()); + let a = rect_item(&mut d, Rect::new(72.0, 72.0, 172.0, 122.0), Shape::Rectangle); + let b = rect_item(&mut d, Rect::new(200.0, 72.0, 300.0, 122.0), Shape::Oval); + let c = rect_item(&mut d, Rect::new(72.0, 200.0, 172.0, 300.0), Shape::Rectangle); + edit(&mut d, a, |it| { + it.stroke = Stroke { swatch: "[Black]".into(), weight: 2.0, kind: StrokeType::Dashed { pattern: vec![4.0, 2.0] }, ..Stroke::default() }; + it.opacity = 0.5; + }); + edit(&mut d, b, |it| it.effects.drop_shadow.on = true); + edit(&mut d, c, |it| { + let ctr = it.bounds().center(); + it.xf = Affine::rotate_about(30f64.to_radians(), ctr); + }); + let mut z = export(&d, &PptxOptions::default()); + let s = part(&mut z, "ppt/slides/slide1.xml"); + // 72 pt from the page corner, 100 × 50 pt. + assert!(s.contains(""), "{s}"); + assert!(s.contains("")); + assert!(s.contains(""), "{s}"); + assert!(s.contains("")); + assert!(s.contains(" 3, "the frame breaks the text into lines"); + let r = export_pptx(&d, &cache, &PptxOptions::default()).unwrap(); + let mut z = unzip(&r.bytes); + let s = part(&mut z, "ppt/slides/slide1.xml"); + assert_eq!(s.matches("").count(), lines - 1, "{s}"); + // The text survives intact (line-end spaces kept, the composer's hyphens shown). + let joined = shape_texts(&s).concat(); + assert_eq!(joined.replace('-', ""), text); + assert!(s.contains("")); + assert_eq!(s.matches("").count(), 2); +} + +#[test] +fn threaded_story_splits_at_its_frames() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega"; + let (f1, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 160.0, 110.0), lid, text, ParaFormat::default()).unwrap(); + let f2 = rect_item(&mut d, Rect::new(300.0, 72.0, 388.0, 400.0), Shape::Rectangle); + edit(&mut d, f2, |it| { + it.fill = Fill::none(); + it.content = Content::Text(designcraft_doc::TextFrame { story: sid, options: TextFrameOptions::default() }); + }); + if let Some(st) = d.stories.get_mut(&sid) { + Arc::make_mut(st).frames = vec![f1, f2]; + } + let mut z = export(&d, &PptxOptions::default()); + let s = part(&mut z, "ppt/slides/slide1.xml"); + // Two text shapes, the story starting in the first and ending in the second. + let texts: Vec = shape_texts(&s).iter().map(|t| t.replace('-', "")).collect(); + assert_eq!(texts.len(), 2, "{s}"); + assert!(texts[0].starts_with("alpha") && !texts[0].contains("omega"), "{texts:?}"); + assert!(texts[1].trim_end().ends_with("omega"), "{texts:?}"); + assert_eq!(texts.concat().trim_end(), text); +} + +fn png(w: u32, h: u32) -> Vec { + let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() +} + +#[test] +fn pictures_are_cropped_to_their_frames() { + let mut d = Document::new(&NewDocument::default()); + let asset = AssetId(d.alloc()); + d.assets.insert( + asset, + Arc::new(Asset { + id: asset, + name: "red.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(png(200, 100)), + pixels: Some((200, 100)), + page: 0, + }), + ); + let id = rect_item(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0), Shape::Rectangle); + edit(&mut d, id, |it| { + it.fill = Fill::none(); + // 200 × 100 image at 1:1, its left quarter cut off by the frame. + it.content = Content::Graphic(Graphic { + asset, + size: (200.0, 100.0), + xf: Affine::translate((50.0, 100.0)), + auto_fit: designcraft_doc::Fitting::None, + fit_align: 0, + crop: [0.0; 4], + }); + }); + let mut z = export(&d, &PptxOptions::default()); + assert!(z.by_name("ppt/media/image1.png").is_ok()); + let s = part(&mut z, "ppt/slides/slide1.xml"); + assert!(s.contains(""), "{s}"); + let rels = part(&mut z, "ppt/slides/_rels/slide1.xml.rels"); + assert!(rels.contains("../media/image1.png")); +} + +#[test] +fn bad_pages_and_hostile_geometry_are_errors_not_crashes() { + let mut d = Document::new(&NewDocument::default()); + let r = export_pptx(&d, &Cache::new(), &PptxOptions { pages: Some(vec![5]), ..Default::default() }); + assert!(matches!(r, Err(PptxError::BadPage(6)))); + assert!(matches!(export_pptx(&d, &Cache::new(), &PptxOptions { pages: Some(vec![]), ..Default::default() }), Err(PptxError::NoPages))); + let a = rect_item(&mut d, Rect::new(10.0, 10.0, 10.0, 10.0), Shape::Rectangle); + let b = rect_item(&mut d, Rect::new(10.0, 10.0, 50.0, 50.0), Shape::Rectangle); + edit(&mut d, a, |it| it.xf = Affine::new([0.0, 0.0, 0.0, 0.0, 0.0, 0.0])); + edit(&mut d, b, |it| it.xf = Affine::new([f64::NAN, 0.0, 0.0, 1e300, f64::INFINITY, 0.0])); + let lid = d.default_layer(); + let (t, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 60.0, 60.0), lid, "x", ParaFormat::default()).unwrap(); + edit(&mut d, t, |it| it.xf = Affine::new([1.0, 0.0, 3.0, 1.0, 0.0, 0.0])); + let mut z = export(&d, &PptxOptions::default()); + well_formed(&mut z); +} + +#[test] +fn fonts_embed_as_embedded_opentype() { + let face = designcraft_fonts::FontDb::global().face("Source Serif 4", "Regular"); + let eot = crate::fonts::eot(&face).unwrap(); + let le32 = |at: usize| u32::from_le_bytes([eot[at], eot[at + 1], eot[at + 2], eot[at + 3]]); + assert_eq!(le32(0) as usize, eot.len()); + assert_eq!(le32(4) as usize, face.data().len()); + assert_eq!(le32(8), 0x0002_0001); + assert_eq!(u16::from_le_bytes([eot[34], eot[35]]), 0x504C, "magic"); + assert!(eot.ends_with(face.data())); + + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Embedded", ParaFormat::default()).unwrap(); + let mut z = export(&d, &PptxOptions { embed_fonts: true, ..Default::default() }); + let pres = part(&mut z, "ppt/presentation.xml"); + assert!( + pres.contains("embedTrueTypeFonts=\"1\"") && pres.contains(" bool { + ('\u{E000}'..='\u{E1FF}').contains(&c) +} + +/// One piece of a line: a cluster's text and where the composer put it. +#[derive(Clone, Debug)] +struct Unit { + text: String, + style: u32, + face: designcraft_fonts::FaceRef, + size: f64, + /// Baseline shift (points, down positive). + y: f64, + x: f64, + end: f64, + /// Width PowerPoint gives the text without extra spacing. + natural: f64, + tab: bool, + rs: Option, +} + +/// A run of units sharing style and character spacing. +#[derive(Clone, Debug)] +struct Run { + text: String, + style: u32, + face: designcraft_fonts::FaceRef, + size: f64, + y: f64, + /// Character spacing, points. + spc: f64, + rs: Option, +} + +/// A laid-out line ready to write. +#[derive(Clone, Debug)] +pub(crate) struct OutLine { + runs: Vec, + /// Text-space x where the line's first unit starts. + start: f64, + /// Text-space x where it ends. + end: f64, + baseline: f64, + descent: f64, + /// Largest font size on the line (PowerPoint's line height follows it). + size: f64, + /// The composer's leading for the line. + leading: f64, + tabs: Vec, + para: usize, + last_in_para: bool, + /// The line ends at a space the composer doesn't draw. + trailing_space: bool, +} + +/// Lines that share a text box. +#[derive(Clone, Debug, Default)] +pub(crate) struct Segment { + pub lines: Vec, + pub x0: f64, + pub x1: f64, +} + +pub fn frame(ex: &mut Exporter, it: &Item, tfr: &TextFrame, xf: Affine, opacity: f32) { + let doc = ex.doc; + let cs = ex.cache.get(doc, tfr.story, ex.page_name.as_deref()); + let Some(ft) = cs.frame(it.id) else { + ex.shape(it, xf, opacity, None); + return; + }; + let story_text = doc.story(tfr.story).map(|s| s.text.clone()).unwrap_or_default(); + let vertical = doc.frame_vertical(it); + if vertical { + ex.warn("vertical text frames are exported with PowerPoint's vertical text (line breaks may differ)"); + } + let m = ex.to_slide * xf; + if ex.opts.line_breaks == LineBreaks::Reflow || vertical { + reflow(ex, it, tfr, ft, &cs, &story_text, xf, opacity, vertical); + return; + } + let Some((_, dm)) = box_of(it.inner_bounds(), m) else { + ex.warn("skewed text frames are exported unskewed"); + // Upright where the skewed frame's box is (its parents' transforms included). + let inner = it.inner_bounds(); + let mut plain = it.clone(); + plain.xf = Affine::translate(xf.transform_rect_bbox(inner).origin().to_vec2() - inner.origin().to_vec2()); + frame(ex, &plain, tfr, plain.xf, opacity); + return; + }; + let k = (dm.sx * dm.sy).abs().sqrt(); + let table_lines: Vec = ft.tables.iter().map(|t| t.line).collect(); + let segs = segments(ex, &cs, ft, &story_text, &table_lines); + let simple = segs.len() == 1 && ft.notes.is_empty() && ft.tables.is_empty() && ft.decos.is_empty(); + if simple && let Some(seg) = segs.first() { + // One box: the frame itself carries the text, inset to where the lines sit. + let inner = it.inner_bounds(); + let r = seg_rect(seg); + let insets = [r.x0 - inner.x0, r.y0 - inner.y0, inner.x1 - r.x1, inner.y1 - r.y1]; + if insets.iter().all(|v| *v >= -0.01) { + let body = body(ex, seg, r, k, opacity, insets); + ex.shape(it, xf, opacity, Some(&body)); + return; + } + } + ex.shape(it, xf, opacity, None); + decos(ex, ft, m, opacity); + for seg in &segs { + text_box(ex, seg, m, k, opacity, "Text"); + } + for n in &ft.notes { + if let Some(nft) = n.text.frames.first() { + let nm = m * Affine::translate(n.origin.to_vec2()); + let note = segments(ex, &n.text, nft, &n.source, &[]); + decos(ex, nft, nm, opacity); + for seg in ¬e { + text_box(ex, seg, nm, k, opacity, "Footnote"); + } + } + } + crate::table::tables(ex, ft, m, k, opacity); + anchored(ex, tfr, ft, xf, opacity); +} + +/// Anchored objects in the frame's text. +fn anchored(ex: &mut Exporter, tfr: &TextFrame, ft: &FrameText, xf: Affine, opacity: f32) { + let Some(st) = ex.doc.story(tfr.story) else { return }; + let objects: Vec<_> = ft.objects.iter().filter_map(|o| st.objects.get(o.index).map(|obj| (obj.item.clone(), o.origin))).collect(); + for (item, origin) in objects { + ex.item(&item, xf * Affine::translate(origin.to_vec2()), opacity); + } +} + +/// Paragraph shading and rules. +fn decos(ex: &mut Exporter, ft: &FrameText, m: Affine, opacity: f32) { + for d in &ft.decos { + let Some(c) = ex.color(&d.color, d.tint, opacity) else { continue }; + if let Some((xfrm, _)) = box_of(d.rect, m) { + ex.rect_shape(xfrm, &format!("{c}"), "Paragraph Shading"); + } + } +} + +pub(crate) fn seg_rect(seg: &Segment) -> Rect { + let first = seg.lines.first(); + let top = first.map_or(0.0, |l| l.baseline - BASELINE_IN_LINE * line_height(seg, 0)); + let bottom = seg.lines.last().map_or(top + 1.0, |l| l.baseline + l.descent); + Rect::new(seg.x0, top, seg.x1.max(seg.x0 + 1.0), bottom.max(top + 1.0)) +} + +/// The PowerPoint line height used for line `i` of a segment: the baseline distance inside its +/// paragraph, else the composer's leading for the line. +fn line_height(seg: &Segment, i: usize) -> f64 { + let Some(l) = seg.lines.get(i) else { return 12.0 }; + para_step(seg, i).unwrap_or(if l.leading > 0.0 { l.leading } else { l.size * SINGLE }) +} + +/// The baseline-to-baseline distance inside line `i`'s paragraph (the median, in this segment). +fn para_step(seg: &Segment, i: usize) -> Option { + let para = seg.lines.get(i)?.para; + let ls: Vec<&OutLine> = seg.lines.iter().filter(|l| l.para == para).collect(); + let mut d: Vec = ls.windows(2).map(|w| w[1].baseline - w[0].baseline).filter(|v| *v > 0.0).collect(); + if d.is_empty() { + return None; + } + d.sort_by(f64::total_cmp); + d.get(d.len() / 2).copied() +} + +fn text_box(ex: &mut Exporter, seg: &Segment, m: Affine, k: f64, opacity: f32, name: &str) { + let r = seg_rect(seg); + let Some((xfrm, _)) = box_of(r, m) else { return }; + let body = body(ex, seg, r, k, opacity, [0.0; 4]); + write_box(ex, xfrm, &body, name); +} + +fn write_box(ex: &mut Exporter, xfrm: XfrmBox, body: &str, name: &str) { + let id = ex.id(); + let _ = write!( + ex.out, + "{}{body}", + xfrm.xml() + ); +} + +/// Cut a frame's lines into segments and lay out their runs. +pub(crate) fn segments(ex: &mut Exporter, cs: &ComposedStory, ft: &FrameText, story: &str, skip: &[usize]) -> Vec { + let mut out: Vec = Vec::new(); + let mut prev: Option<&Line> = None; + for (i, l) in ft.lines.iter().enumerate() { + if skip.contains(&i) { + prev = None; + continue; + } + let ol = out_line(ex, cs, l, story); + let joins = prev.is_some_and(|p| { + p.column == l.column + && (p.x0 - l.x0).abs() < 0.5 + && (p.x1 - l.x1).abs() < 0.5 + && l.baseline > p.baseline + && l.baseline - p.baseline < 2.5 * p.leading.max(l.leading).max(ol.size) + }); + match out.last_mut() { + Some(seg) if joins => seg.lines.push(ol), + _ => out.push(Segment { lines: vec![ol], x0: l.x0, x1: l.x1 }), + } + prev = Some(l); + } + out +} + +/// The text for glyph `g`: the story's characters, or what the glyph shows when the composer +/// inserted or changed it (page numbers, list labels, hyphens, case changes). +fn glyph_text(ex: &mut Exporter, g: &PlacedGlyph, story: &str, prev_src: &mut Option) -> Option { + let src = story.get(g.byte..g.byte.saturating_add(g.len)).filter(|t| !t.is_empty() && !t.chars().any(is_marker)); + let face = g.face.get(); + if let Some(t) = src { + *prev_src = Some(g.byte); + // A single character drawn as another glyph (All Caps): what the glyph shows. + let mut cs = t.chars(); + if let (Some(c), None) = (cs.next(), cs.next()) + && let Some(shown) = ex.fonts.char_of(face, g.gid) + && shown != c + && ex.fonts.glyph(face, c) != Some(g.gid) + && shown.to_lowercase().eq(c.to_lowercase()) + { + return Some(shown.to_string()); + } + return Some(t.to_string()); + } + if g.len == 0 && *prev_src == Some(g.byte) { + // Further glyphs of a cluster already written. + return None; + } + *prev_src = None; + ex.fonts.char_of(face, g.gid).map(|c| c.to_string()) +} + +fn out_line(ex: &mut Exporter, cs: &ComposedStory, l: &Line, story: &str) -> OutLine { + let mut units: Vec = Vec::new(); + let mut prev_src = None; + let is_tab = |g: &PlacedGlyph| story.get(g.byte..g.byte.saturating_add(g.len)) == Some("\t"); + let last_shown = l.glyphs.iter().rposition(|g| g.visible || is_tab(g)); + let mut trailing_space = false; + for (i, g) in l.glyphs.iter().enumerate() { + let next_x = l.glyphs.get(i + 1).map_or(g.x + g.adv, |n| n.x); + let is_tab = is_tab(g); + if !g.visible && !is_tab { + if last_shown.is_some_and(|k| i > k) { + trailing_space |= story.get(g.byte..g.byte.saturating_add(g.len)).is_some_and(|t| t.chars().any(char::is_whitespace)); + } else if let Some(u) = units.last_mut() { + // Invisible glyphs inside the line still take up their room. + u.end = next_x.max(u.end); + } + continue; + } + let face = g.face.get(); + let size = g.sy * face.upem; + let text = if is_tab { Some("\t".to_string()) } else { glyph_text(ex, g, story, &mut prev_src) }; + let Some(text) = text else { + if let Some(u) = units.last_mut() { + u.end = next_x.max(u.end); + } + continue; + }; + let natural: f64 = if is_tab { 0.0 } else { text.chars().map(|c| ex.fonts.advance(face, c).unwrap_or(face.upem * 0.5) * g.sy).sum() }; + let rs = cs.styles.get(g.style as usize).cloned(); + units.push(Unit { text, style: g.style, face: g.face, size, y: g.y, x: g.x, end: next_x, natural, tab: is_tab, rs }); + } + // A space the composer left out of the line (it ends there): in the story at the line's end. + let end_byte = l.glyphs.iter().filter(|g| g.len > 0).map(|g| g.byte.saturating_add(g.len)).max().unwrap_or(l.range.end); + let after = story.get(end_byte.min(story.len())..).and_then(|t| t.chars().next()); + trailing_space |= !l.last_in_para && after.is_some_and(|c| c.is_whitespace() && c != '\n' && c != '\r'); + let start = units.first().map_or(l.x0, |u| u.x); + let end = units.last().map_or(l.x0, |u| u.end); + let mut runs: Vec = Vec::new(); + let mut tabs = Vec::new(); + // PowerPoint's pen, and the spacing of the run it is in. + let mut pen = start; + for u in &units { + if u.tab { + tabs.push(u.end); + runs.push(Run { text: "\t".into(), style: u.style, face: u.face, size: u.size, y: u.y, spc: 0.0, rs: u.rs.clone() }); + pen = u.end; + continue; + } + let n = u.text.chars().count().max(1) as f64; + if let Some(r) = runs.last_mut().filter(|r| !r.text.ends_with('\t')) + && r.style == u.style + && r.face.get().id() == u.face.get().id() + && (r.size - u.size).abs() < 0.005 + && (r.y - u.y).abs() < 0.01 + && (pen + u.natural + r.spc * n - u.end).abs() <= TOLERANCE + { + r.text.push_str(&u.text); + pen += u.natural + r.spc * n; + continue; + } + // A new run whose spacing lands this unit where the composer put it (0.01 pt steps). + let spc = ((u.end - pen - u.natural) / n * 100.0).round() / 100.0; + let spc = if spc.is_finite() { spc.clamp(-100.0, 100.0) } else { 0.0 }; + runs.push(Run { text: u.text.clone(), style: u.style, face: u.face, size: u.size, y: u.y, spc, rs: u.rs.clone() }); + pen += u.natural + spc * n; + } + let size = units.iter().map(|u| u.size).fold(0.0, f64::max); + OutLine { + runs, + start, + end, + baseline: l.baseline, + descent: l.descent, + size: if size > 0.0 { size } else { (l.ascent + l.descent).max(1.0) / SINGLE }, + leading: l.leading, + tabs, + para: l.para, + last_in_para: l.last_in_para, + trailing_space, + } +} + +/// Paragraph alignment read off the lines: centred and right-aligned lines leave room on both or +/// the left side; everything else starts at its left indent. +fn align_of(seg: &Segment, lines: &[OutLine]) -> &'static str { + let gaps: Vec<(f64, f64)> = lines.iter().map(|l| (l.start - seg.x0, seg.x1 - l.end)).collect(); + let roomy = |g: &(f64, f64)| g.0 > 1.0 || g.1 > 1.0; + if !gaps.iter().any(roomy) { + return "l"; + } + if gaps.iter().all(|(a, b)| (a - b).abs() < 0.75) && gaps.iter().any(|(a, _)| *a > 1.0) { + return "ctr"; + } + // Flush right with room on the left: more than an indented first line of justified text. + let indented = gaps.iter().filter(|(a, _)| *a > 1.0).count(); + if gaps.iter().all(|(_, b)| *b < 0.75) && (indented > 1 || (gaps.len() == 1 && indented == 1)) { + return "r"; + } + "l" +} + +/// `` for a segment whose box is `r` (text space); `insets` place the text inside a +/// larger shape (left, top, right, bottom). +fn body(ex: &mut Exporter, seg: &Segment, r: Rect, k: f64, opacity: f32, insets: [f64; 4]) -> String { + let [li, ti, ri, bi] = insets.map(|v| emu_len(v * k)); + format!( + "{}", + paragraphs(ex, seg, r, k, opacity) + ) +} + +/// The `` elements of a segment whose box is `r` (text space). +pub(crate) fn paragraphs(ex: &mut Exporter, seg: &Segment, r: Rect, k: f64, opacity: f32) -> String { + let mut s = String::new(); + let mut prev_end: Option<(f64, f64)> = None; + // The segment's lines, a paragraph at a time; `at` is the index of each one's first line. + let mut at = 0; + for lines in seg.lines.chunk_by(|a, b| a.para == b.para && !a.last_in_para) { + let i = at; + at += lines.len(); + let (Some(first), Some(last)) = (lines.first(), lines.last()) else { continue }; + let mut h = line_height(seg, i); + let r_in = BASELINE_IN_LINE; + if let Some((prev_base, prev_h)) = prev_end + && lines.len() == 1 + { + // A one-line paragraph closer to the line before than its own line box allows (tight + // display type): a smaller box puts it there. + let natural = (1.0 - r_in) * prev_h + r_in * h; + let gap = first.baseline - prev_base; + if gap < natural { + h = ((gap - (1.0 - r_in) * prev_h) / r_in).max(first.size * 0.2); + } + } + let algn = align_of(seg, lines); + let mut ppr = format!(" 0.005 { + let _ = write!(ppr, " marL=\"{}\"", emu(rest * k)); + } + if ind.abs() > 0.005 { + let _ = write!(ppr, " indent=\"{}\"", emu(ind * k)); + } + } + ppr.push('>'); + let _ = write!(ppr, "", pct(h / (first.size * SINGLE))); + if let Some((prev_base, prev_h)) = prev_end { + // PowerPoint puts the next line one line box down: the rest of the previous line's box + // and the top part of this one. Space Before makes up the difference. + let natural = (1.0 - r_in) * prev_h + r_in * h; + let before = first.baseline - prev_base - natural; + if before > 0.005 { + let _ = write!(ppr, "", (before * k * 100.0).round() as i64); + } + } + let tabs: Vec = lines.iter().flat_map(|l| l.tabs.iter().copied()).collect(); + if !tabs.is_empty() { + ppr.push_str(""); + let mut sorted = tabs.clone(); + sorted.sort_by(f64::total_cmp); + sorted.dedup_by(|a, b| (*a - *b).abs() < 0.01); + for t in sorted { + let _ = write!(ppr, "", emu((t - r.x0) * k)); + } + ppr.push_str(""); + } + ppr.push_str(""); + s.push_str(""); + s.push_str(&ppr); + for (n, l) in lines.iter().enumerate() { + if n > 0 { + let _ = write!(s, "{}", rpr(ex, l.runs.first(), l.size, k, opacity, "a:rPr")); + } + let n_runs = l.runs.len(); + for (ri, run) in l.runs.iter().enumerate() { + // The undrawn space at a left-aligned line's end goes back in (invisible there), + // so removing the break doesn't join words. + let tail = if ri + 1 == n_runs && l.trailing_space && algn == "l" && !run.text.ends_with(char::is_whitespace) { " " } else { "" }; + let _ = write!(s, "{}{}{tail}", rpr(ex, Some(run), run.size, k, opacity, "a:rPr"), esc(&run.text)); + } + } + s.push_str(&rpr(ex, last.runs.last(), last.size, k, opacity, "a:endParaRPr")); + s.push_str(""); + prev_end = Some((last.baseline, h)); + } + s +} + +/// Run properties (`a:rPr`, or `a:endParaRPr` for the paragraph mark). +fn rpr(ex: &mut Exporter, run: Option<&Run>, size: f64, k: f64, opacity: f32, tag: &str) -> String { + // PowerPoint draws raised and lowered text at 2/3 of its size: state it larger. + let shifted = run.is_some_and(|r| r.y.abs() > 0.01 && r.size > 0.0); + let size = if shifted { size * 1.5 } else { size }; + let sz = ((size * k * 100.0).round() as i64).clamp(100, 400_000); + let Some(run) = run else { return format!("<{tag} lang=\"en-US\" sz=\"{sz}\" dirty=\"0\"/>") }; + let cs = run.rs.as_ref(); + let font = ex.fonts.use_face(run.face); + let mut a = format!("<{tag} lang=\"en-US\" sz=\"{sz}\""); + let italic = font.italic || cs.is_some_and(|c| c.skew.abs() > 0.01); + if font.bold { + a.push_str(" b=\"1\""); + } + if italic { + a.push_str(" i=\"1\""); + } + if let Some(st) = cs { + if st.underline { + a.push_str(" u=\"sng\""); + } + if st.strikethrough { + a.push_str(" strike=\"sngStrike\""); + } + } + // Kerning off: the composer's kerning is in the character spacing already. + a.push_str(" kern=\"400000\""); + let spc = (run.spc * k * 100.0).round() as i64; + if spc != 0 && tag == "a:rPr" { + let _ = write!(a, " spc=\"{spc}\""); + } + if shifted { + let _ = write!(a, " baseline=\"{}\"", pct(-run.y / size)); + } + a.push_str(" dirty=\"0\">"); + if let Some(st) = cs { + if st.stroke != designcraft_color::swatch::NONE + && st.stroke_weight > 0.0 + && let Some(c) = ex.color(&st.stroke, st.stroke_tint, opacity) + { + let _ = write!(a, "{c}", emu_len(st.stroke_weight * k)); + } + match ex.color(&st.fill, st.fill_tint, opacity) { + Some(c) => { + let _ = write!(a, "{c}"); + } + None => a.push_str(""), + } + } + let fam = esc(&font.family); + let _ = write!(a, ""); + a +} + +/// Reflow: the frame as one shape whose text PowerPoint sets, in the frame's columns. +#[allow(clippy::too_many_arguments)] +fn reflow(ex: &mut Exporter, it: &Item, tfr: &TextFrame, ft: &FrameText, cs: &ComposedStory, story: &str, xf: Affine, opacity: f32, vertical: bool) { + let doc = ex.doc; + let Some(st) = doc.story(tfr.story) else { + ex.shape(it, xf, opacity, None); + return; + }; + let o = &tfr.options; + let m = ex.to_slide * xf; + let k = box_of(it.inner_bounds(), m).map_or(1.0, |(_, d)| (d.sx * d.sy).abs().sqrt()); + let [t, l, b, r] = o.inset.map(|v| emu_len(v * k)); + let cols = ft.columns.len().max(1); + let mut s = format!(" 1 { + let _ = write!(s, " numCol=\"{cols}\" spcCol=\"{}\"", emu_len(o.gutter * k)); + } + if vertical { + s.push_str(" vert=\"eaVert\""); + } + let anchor = match o.vertical_justification { + designcraft_doc::VerticalJustification::Center => "ctr", + designcraft_doc::VerticalJustification::Bottom => "b", + designcraft_doc::VerticalJustification::Justify => "just", + designcraft_doc::VerticalJustification::Top => "t", + }; + let _ = write!(s, " anchor=\"{anchor}\">"); + // The paragraphs this frame shows, with the runs of their composed glyphs. + let ranges = st.para_ranges(); + let mut first = true; + for (pi, range) in ranges.iter().enumerate() { + if range.end < ft.range.start || range.start >= ft.range.end.max(ft.range.start + 1) { + continue; + } + let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi).collect(); + let Some(pf) = st.paras.get(pi) else { continue }; + let (pp, chars) = doc.styles.resolve_para(pf); + let algn = match pp.align { + Align::Center => "ctr", + Align::Right => "r", + Align::LeftJustified | Align::CenterJustified | Align::RightJustified => "just", + Align::FullyJustified => "dist", + _ => "l", + }; + let mut ppr = format!(" 0.005 { + let _ = write!(ppr, " marL=\"{}\"", emu(pp.left_indent * k)); + } + if pp.right_indent.abs() > 0.005 { + let _ = write!(ppr, " marR=\"{}\"", emu(pp.right_indent * k)); + } + if pp.first_line_indent.abs() > 0.005 { + let _ = write!(ppr, " indent=\"{}\"", emu(pp.first_line_indent * k)); + } + ppr.push('>'); + let size = chars.size.max(1.0); + let lead = match lines.windows(2).map(|w| w[1].baseline - w[0].baseline).find(|d| *d > 0.0) { + Some(d) => d, + None => lines.first().map_or(size * pp.auto_leading, |l| l.leading.max(1.0)), + }; + let _ = write!(ppr, "", pct(lead / (size * SINGLE))); + if !first && pp.space_before > 0.0 { + let _ = write!(ppr, "", (pp.space_before * k * 100.0).round() as i64); + } + if pp.space_after > 0.0 { + let _ = write!(ppr, "", (pp.space_after * k * 100.0).round() as i64); + } + ppr.push_str(""); + s.push_str(""); + s.push_str(&ppr); + let mut last_size = size; + for l in &lines { + let ol = out_line(ex, cs, l, story); + let mut runs: Vec = Vec::new(); + for mut r in ol.runs { + // PowerPoint sets the spacing; only tracking-sized adjustments would survive, and + // they vary line to line, so none is kept. + r.spc = 0.0; + match runs.last_mut() { + Some(p) + if p.style == r.style + && p.face.get().id() == r.face.get().id() + && (p.size - r.size).abs() < 0.005 + && (p.y - r.y).abs() < 0.01 => + { + p.text.push_str(&r.text) + } + _ => runs.push(r), + } + } + // Composer hyphens at line ends go: PowerPoint breaks the lines itself. + if l.hyphenated + && let Some(r) = runs.last_mut() + && r.text.ends_with(['-', '\u{2010}']) + { + r.text.pop(); + } + if ol.trailing_space + && let Some(r) = runs.last_mut() + && !r.text.ends_with(char::is_whitespace) + { + r.text.push(' '); + } + for run in &runs { + let _ = write!(s, "{}{}", rpr(ex, Some(run), run.size, k, opacity, "a:rPr"), esc(&run.text)); + last_size = run.size; + } + } + s.push_str(&rpr(ex, None, last_size, k, opacity, "a:endParaRPr")); + s.push_str(""); + first = false; + } + s.push_str(""); + ex.shape(it, xf, opacity, Some(&s)); +} diff --git a/crates/pptx/src/xml.rs b/crates/pptx/src/xml.rs new file mode 100644 index 000000000..4eba791dd --- /dev/null +++ b/crates/pptx/src/xml.rs @@ -0,0 +1,75 @@ +//! XML text helpers and DrawingML units. + +/// English Metric Units per point. +pub const EMU_PER_PT: f64 = 12_700.0; + +/// Points → EMU, saturating (input-derived geometry may be huge or not finite). +pub fn emu(pt: f64) -> i64 { + if !pt.is_finite() { + return 0; + } + (pt * EMU_PER_PT).round().clamp(-27_273_042_316_900.0, 27_273_042_316_900.0) as i64 +} + +/// A non-negative extent in EMU (DrawingML rejects negative sizes). +pub fn emu_len(pt: f64) -> i64 { + emu(pt).max(0) +} + +/// Degrees → DrawingML angle units (60 000ths of a degree), normalised to 0..360°. +pub fn angle(deg: f64) -> i64 { + if !deg.is_finite() { + return 0; + } + (deg.rem_euclid(360.0) * 60_000.0).round() as i64 % 21_600_000 +} + +/// A 0..1 fraction → DrawingML percentage units (1000ths of a percent). +pub fn pct(v: f64) -> i64 { + if !v.is_finite() { + return 0; + } + (v * 100_000.0).round().clamp(-100_000_000.0, 100_000_000.0) as i64 +} + +/// Escape text for element content and attribute values. Characters XML 1.0 can't hold are +/// dropped. +pub fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + '\'' => o.push_str("'"), + '\t' | '\n' | '\r' => o.push(c), + c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {} + c => o.push(c), + } + } + o +} + +/// sRGB 0..1 → `RRGGBB`. +pub fn hex(rgb: [f32; 3]) -> String { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + format!("{:02X}{:02X}{:02X}", q(rgb[0]), q(rgb[1]), q(rgb[2])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn units() { + assert_eq!(emu(1.0), 12_700); + assert_eq!(emu(f64::NAN), 0); + assert_eq!(emu_len(-3.0), 0); + assert_eq!(angle(-90.0), 16_200_000); + assert_eq!(angle(360.0), 0); + assert_eq!(pct(0.5), 50_000); + assert_eq!(esc("a, &str)] = &[ ), ("app.exportFixedEpub", "Export EPUB (Fixed Layout)…", None, "{path?} — pre-paginated EPUB 3"), ("app.exportHtml", "Export HTML…", None, "{path?} — one self-contained web page"), + ("app.exportPptx", "Export PowerPoint…", None, "{path?} — every page as a slide of editable shapes, pictures and text"), ("app.exportXml", "Export XML…", None, "{path?} — the tagged content"), ("app.printBooklet", "Print Booklet…", None, "{path?} — saddle-stitched printer spreads as PDF"), ("app.qrCode", "Generate QR Code…", None, "{} — the QR Code dialog (object.qrCode does the work)"), @@ -180,6 +181,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.exportFixedEpub", "ui:app.exportInteractivePdf", "ui:app.exportHtml", + "ui:app.exportPptx", "ui:app.exportXml", "ui:app.exportText", ">Export", @@ -753,6 +755,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option export_bytes(app, p, "epub", "file.exportFixedEpub"), "app.exportHtml" => export_bytes(app, p, "html", "file.exportHtml"), + "app.exportPptx" => export_bytes(app, p, "pptx", "file.exportPptx"), "app.exportXml" => export_bytes(app, p, "xml", "file.exportXml"), "app.printBooklet" => export_bytes(app, p, "pdf", "file.printBooklet"), "app.menus" => { diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3e9e28c0a..a95f34117 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -43,6 +43,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("pdf", Class::Layer(3)), ("idml", Class::Layer(3)), ("epub", Class::Layer(3)), + ("pptx", Class::Layer(3)), ("textimport", Class::Layer(3)), ("format", Class::Layer(3)), ("tools", Class::Layer(4)), From a075efccd8ab1eccb9db12e48c9bbca2309b974d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Thu, 8 Oct 2026 23:38:08 +0200 Subject: [PATCH 002/128] chore: add contributor name for PhilippKrueger --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db208..fe264b640 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,6 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.PhilippKrueger] +real_name = "Philipp Krüger" From f05e8f5d9876bb85dfbfe5c084331beefae75707 Mon Sep 17 00:00:00 2001 From: Michael Townshend <1122169+ironinthesoul@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:41:14 +0200 Subject: [PATCH 003/128] #201: route macOS text clipboard commands through egui Forward native Copy/Cut/Paste for active canvas text through egui so the desktop integration can use the system clipboard. Add regression coverage for the native clipboard event path. Co-authored-by: GitHub Copilot <198982749+Copilot@users.noreply.github.com> --- crates/ui-egui/src/menus.rs | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 94e5c0971..e930a12be 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1928,9 +1928,15 @@ fn clipped_submenu<'a, R>( /// ⌘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); + if params.is_null() && ctx.text_edit_focused() && forward_to_text_edit(app, ctx, id) { + return; } + // The canvas text editor is not an egui TextEdit, so its clipboard events must pass through + // egui for the desktop integration to read/write the macOS clipboard. + if params.is_null() && app.session.wants_text() && matches!(id, "edit.copy" | "edit.cut" | "edit.paste") && forward_to_text_edit(app, ctx, id) { + return; + } + activate(app, id, params); } /// Whether a native menu bar item can be chosen: as in the in-window menus, but the editing @@ -1944,7 +1950,7 @@ const TEXT_FIELD_COMMANDS: [&str; 6] = ["edit.selectAll", "edit.copy", "edit.cut /// 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 { +fn forward_to_text_edit(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 }) }; @@ -2957,6 +2963,31 @@ mod tests { assert_eq!(document_items(&app), (2, 0), "nothing was pasted into the document"); } + #[test] + fn native_copy_and_paste_use_the_system_clipboard_for_canvas_text() { + let ctx = egui::Context::default(); + 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], "content": "text", "text": "hello"})).unwrap(); + let story = *app.session.active().unwrap().doc.stories.keys().next().unwrap(); + app.select_tool("type"); + app.run("text.select", json!({"story": story.0, "anchor": 0, "focus": 5})).unwrap(); + for _ in 0..3 { + native_frame(&mut app, &ctx, vec![], None); + } + assert!(app.session.wants_text()); + + native_frame(&mut app, &ctx, vec![], Some("edit.copy")); + let copied = native_frame(&mut app, &ctx, vec![], None); + assert!(copied.platform_output.commands.contains(&egui::OutputCommand::CopyText("hello".into()))); + + let paste = native_frame(&mut app, &ctx, vec![], Some("edit.paste")); + let commands = &paste.viewport_output[&egui::ViewportId::ROOT].commands; + assert!(commands.contains(&egui::ViewportCommand::RequestPaste), "{commands:?}"); + native_frame(&mut app, &ctx, vec![egui::Event::Paste("world".into())], None); + assert_eq!(app.session.active().unwrap().doc.story(story).unwrap().text, "world"); + } + #[test] fn native_undo_and_redo_act_on_the_focused_field() { let ctx = egui::Context::default(); From 2b55f2ed0d9e76c8f0327194e850fbc21fe10a29 Mon Sep 17 00:00:00 2001 From: Middle Disc Date: Sun, 11 Oct 2026 12:16:18 +0330 Subject: [PATCH 004/128] Layout: parent-page margins and guides, Create Guides on the current page, ruler-unit-aware Margins and Columns Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01XWRYQ5eieuUrtxhdbh6goq --- crates/doc/src/build.rs | 23 +++- crates/engine/src/cmd/guides.rs | 48 ++++++++ crates/engine/src/cmd/layout.rs | 88 ++++++++++--- crates/tools/src/snap.rs | 10 +- crates/ui-egui/src/canvas.rs | 42 ++++++- crates/ui-egui/src/dialogs.rs | 156 +++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 8 ++ crates/ui-egui/src/panels/properties.rs | 15 ++- 8 files changed, 364 insertions(+), 26 deletions(-) diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c274f2130..33256d338 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{ItemId, LayerId, PageId, SpreadId}; use crate::item::Item; -use crate::page::{Columns, Margins, Page, PageSide, ParentInfo, Section, Spread}; +use crate::page::{Columns, Guide, Margins, Orientation, Page, PageSide, ParentInfo, Section, Spread}; use crate::styles::Styles; use crate::{DataMerge, DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, ParaFormat, Result, SpreadRef}; @@ -552,6 +552,27 @@ impl Document { let idx = if ps.pages.len() >= 2 && page.side == PageSide::Left { 0 } else { ps.pages.len().checked_sub(1)? }; Some((pi, idx)) } + + /// Ruler guides document page `abs` inherits from its applied parent page. They are stored on + /// the parent page only: positions are moved from the parent page's place in its spread to + /// this page's place in its own, so they draw and snap where the page is. Editing them + /// happens on the parent page. + pub fn inherited_guides(&self, abs: usize) -> Vec { + let (Some(page), Some((pi, qi))) = (self.page(abs), self.parent_page_for(abs)) else { return Vec::new() }; + let Some(parent) = self.parents[pi].pages.get(qi) else { return Vec::new() }; + let dx = page.x - parent.x; + parent + .guides + .iter() + .map(|g| { + let mut g = g.clone(); + if g.orientation == Orientation::Vertical { + g.position += dx; + } + g + }) + .collect() + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 47aacdc86..94d2319de 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -404,4 +404,52 @@ mod layer_tests { let d = s.doc().unwrap().doc.clone(); assert!(g(&s).visible_in(&d) && !g(&s).editable_in(&d)); } + + #[test] + fn create_guides_goes_to_the_named_spread_and_page_only() { + use designcraft_doc::SpreadRef; + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3})).unwrap(); + // The UI passes the spread/page being worked on as a serialised SpreadRef (page 3). + let spread = serde_json::to_value(SpreadRef::Doc(1)).unwrap(); + s.execute("layout.createGuides", &json!({"spread": spread, "page": 1, "columns": 2, "columnGutter": 0.0})).unwrap(); + let l = s.execute("guide.list", &json!({"spread": 1})).unwrap(); + let l = l.as_array().unwrap(); + assert!(!l.is_empty() && l.iter().all(|g| g["page"] == 1), "only page 3: {l:?}"); + let first = s.execute("guide.list", &json!({"spread": 0})).unwrap(); + assert!(first.as_array().unwrap().is_empty(), "nothing on the first page"); + } + + #[test] + fn guides_on_a_parent_page_show_on_the_pages_that_use_it() { + use designcraft_doc::{PageSide, SpreadRef}; + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4, "facingPages": true})).unwrap(); + // A vertical guide 88 pt inside the parent's right page, and a horizontal one. + let parent = serde_json::to_value(SpreadRef::Parent(0)).unwrap(); + let right_x = s.doc().unwrap().doc.parents[0].pages.last().unwrap().x; + s.execute("guide.add", &json!({"spread": parent, "orientation": "vertical", "position": right_x + 88.0})).unwrap(); + s.execute("guide.add", &json!({"spread": parent, "orientation": "horizontal", "position": 200.0, "at": right_x + 10.0})).unwrap(); + let d = &s.doc().unwrap().doc; + let mut seen = 0; + for abs in 0..d.page_count() { + let page = d.page(abs).unwrap(); + let (pi, qi) = d.parent_page_for(abs).unwrap(); + let inherited = d.inherited_guides(abs); + if d.parents[pi].pages[qi].guides.is_empty() { + assert!(inherited.is_empty(), "page {abs} uses the parent's left page, which has no guides"); + continue; + } + seen += 1; + assert_eq!(page.side, PageSide::Right); + let v = inherited.iter().find(|g| g.orientation == designcraft_doc::Orientation::Vertical).unwrap(); + // Moved to this page's own place in its spread: still 88 pt inside the page. + assert!((v.position - (page.x + 88.0)).abs() < 1e-6, "page {abs}: {v:?} vs x {}", page.x); + let h = inherited.iter().find(|g| g.orientation == designcraft_doc::Orientation::Horizontal).unwrap(); + assert!((h.position - 200.0).abs() < 1e-6); + } + assert!(seen >= 2, "pages on the parent's right side inherit: {seen}"); + // The guides stay on the parent: nothing is copied onto document pages. + assert!((0..d.page_count()).all(|a| d.page(a).unwrap().guides.is_empty())); + } } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..7df688e5f 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -75,6 +75,25 @@ fn dist(r: designcraft_geom::Rect, x: f64) -> f64 { } } +/// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to one page. +fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) { + match p.get("margins") { + Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)), + Some(v @ Value::Object(_)) => { + if let Ok(m) = serde_json::from_value(v.clone()) { + pg.margins = m; + } + } + _ => {} + } + if let Some(c) = p.get("columns").and_then(Value::as_u64) { + pg.columns.count = (c as u32).clamp(1, 216); + } + if let Some(g) = p.get("gutter").and_then(Value::as_f64) { + pg.columns.gutter = g.max(0.0); + } +} + pub fn specs() -> Vec { vec![ cmd!( @@ -191,33 +210,48 @@ pub fn specs() -> Vec { "Margins and Columns…", ["Layout"], None, - "{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins)}", + "{pages?: [index] (default all), parent?: index (edit that parent spread instead; `pages` then index its pages), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins)}", has_doc, |s, p| { let pages: Option> = p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()); let p = p.clone(); let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); + let parent = p.get("parent").and_then(Value::as_u64).map(|v| v as usize); s.edit(|d, _| { let before = adjust.then(|| margin_boxes(d)); - let all: Vec = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect()); - for abs in all { - let Some((si, pi)) = d.page_loc(abs) else { continue }; - let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; - match p.get("margins") { - Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)), - Some(v @ Value::Object(_)) => { - if let Ok(m) = serde_json::from_value(v.clone()) { - pg.margins = m; - } + if let Some(pi) = parent { + // A parent spread: its own pages change, and so do the document pages based + // on it that still have the parent's old margins and columns (not overridden). + let Some(ps) = d.parents.get(pi) else { + return Err(bad("layout.marginsAndColumns", format!("no parent spread {pi}"))); + }; + let idxs: Vec = pages.clone().unwrap_or_else(|| (0..ps.pages.len()).collect()); + let old: Vec<(usize, Margins, designcraft_doc::Columns)> = + idxs.iter().filter_map(|&qi| ps.pages.get(qi).map(|pg| (qi, pg.margins, pg.columns.clone()))).collect(); + for &qi in &idxs { + if let Some(pg) = std::sync::Arc::make_mut(&mut d.parents[pi]).pages.get_mut(qi) { + set_margins_columns(pg, &p); } - _ => {} } - if let Some(c) = p.get("columns").and_then(Value::as_u64) { - pg.columns.count = (c as u32).clamp(1, 216); + for abs in 0..d.page_count() { + let Some((ppi, qi)) = d.parent_page_for(abs) else { continue }; + if ppi != pi { + continue; + } + let Some((_, om, oc)) = old.iter().find(|o| o.0 == qi) else { continue }; + let Some((si, pj)) = d.page_loc(abs) else { continue }; + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pj]; + if pg.margins == *om && pg.columns == *oc { + set_margins_columns(pg, &p); + } } - if let Some(g) = p.get("gutter").and_then(Value::as_f64) { - pg.columns.gutter = g.max(0.0); + } else { + let all: Vec = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect()); + for abs in all { + let Some((si, pi)) = d.page_loc(abs) else { continue }; + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; + set_margins_columns(pg, &p); } } if let Some(b) = before { @@ -899,6 +933,7 @@ mod page_numbering_view_tests { #[cfg(test)] mod adjust_tests { + use designcraft_doc::Margins; use serde_json::json; use crate::Session; @@ -920,4 +955,25 @@ mod adjust_tests { let b2 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); assert_eq!(b, b2); } + + #[test] + fn parent_margins_change_the_parent_and_follow_unoverridden_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + // Page 3 has its own margins (an override); pages 1 and 2 still follow the parent. + s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap(); + let parent_before = s.doc().unwrap().doc.parents[0].pages[0].margins; + assert_eq!(parent_before, Margins::uniform(36.0)); + s.execute("layout.marginsAndColumns", &json!({"parent": 0, "margins": 60, "columns": 2})).unwrap(); + let d = &s.doc().unwrap().doc; + // The parent page itself changed (this is what was drawn on screen). + assert_eq!(d.parents[0].pages[0].margins, Margins::uniform(60.0)); + assert_eq!(d.parents[0].pages[0].columns.count, 2); + // Pages that matched the parent follow; the overridden page keeps its margins. + assert_eq!(d.page(0).unwrap().margins, Margins::uniform(60.0)); + assert_eq!(d.page(1).unwrap().margins, Margins::uniform(60.0)); + assert_eq!(d.page(2).unwrap().margins, Margins::uniform(100.0)); + // An unknown parent is an error, not a silent no-op. + assert!(s.execute("layout.marginsAndColumns", &json!({"parent": 9, "margins": 10})).is_err()); + } } diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 6d5c8f6a5..1ff0b4623 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -1083,9 +1083,15 @@ fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> let flags = axis_flags(req, axis); let (moving_lo, moving_hi) = perp_ends(req.rect, axis); let mut best: Option = None; - for page in &sp.pages { + let first_abs = match req.spread { + SpreadRef::Doc(si) => Some(cx.doc.first_page_of_spread(si)), + _ => None, + }; + for (pidx, page) in sp.pages.iter().enumerate() { let page_bounds = page.bounds(); - for guide in &page.guides { + // Guides set on the applied parent page count as this page's guides. + let inherited = first_abs.map(|f| cx.doc.inherited_guides(f + pidx)).unwrap_or_default(); + for guide in page.guides.iter().chain(inherited.iter()) { if !guide.visible_in(cx.doc) || !guide.position.is_finite() || guide.orientation != axis_orientation(axis) { continue; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e61..9f2b357f9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -153,6 +153,38 @@ pub fn current_page(app: &DesignApp) -> Option { Some(st.doc.first_page_of_spread(si) + pi) } +/// The spread (document or parent) under the view centre and the page index within it: where +/// commands that act "on the page I am working on" should go. +pub fn current_spread_page(app: &DesignApp) -> Option<(SpreadRef, usize)> { + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let i = current_slot(app, &layout)?; + let slot = layout.slots.get(i)?; + let v = app.view()?; + let r = app.canvas_rect?; + let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y)); + let pg = st.doc.spread(slot.spread)?.page_at_x(c.x).unwrap_or(0); + Some((slot.spread, pg)) +} + +/// While parent spreads are being edited: (parent spread index, page index within it) under the +/// view centre. `None` when document pages are shown. +pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> { + let st = app.session.active()?; + if !st.editing_parents { + return None; + } + let layout = CanvasLayout::new(&st.doc, true); + let i = current_slot(app, &layout)?; + let slot = layout.slots.get(i)?; + let SpreadRef::Parent(pi) = slot.spread else { return None }; + let v = app.view()?; + let r = app.canvas_rect?; + let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y)); + let qi = st.doc.parents.get(pi)?.page_at_x(c.x).unwrap_or(0); + Some((pi, qi)) +} + /// Scroll so absolute page `abs` is centred (keeps zoom). pub fn go_to_page(app: &mut DesignApp, abs: usize) { let Some(st) = app.session.active() else { return }; @@ -603,11 +635,17 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document painter.rect_stroke(rect(br), 0.0, Stroke::new(hair(painter), c32(s.bleed_color)), StrokeKind::Middle); } let pasteboard = m.inverse().transform_rect_bbox(layout.pasteboard); - for p in &sp.pages { + let first_abs = match slot.spread { + SpreadRef::Doc(si) => Some(doc.first_page_of_spread(si)), + _ => None, + }; + for (pidx, p) in sp.pages.iter().enumerate() { let pr = p.bounds(); if !app.ui.guides { continue; } + // Guides set on the applied parent page show on this page too. + let inherited = first_abs.map(|f| doc.inherited_guides(f + pidx)).unwrap_or_default(); // Baseline grid. if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold { let g = &s.baseline_grid; @@ -634,7 +672,7 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document } } } - for g in p.guides.iter().filter(|g| g.visible_in(doc)) { + for g in p.guides.iter().chain(inherited.iter()).filter(|g| g.visible_in(doc)) { // Spread guides cross the pasteboard; page guides their page. let span = if g.spread { pasteboard } else { pr }; let (a, bb) = match g.orientation { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3bad..ab616504c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -12,6 +12,10 @@ use crate::theme::semibold; pub struct Dialog { pub id: String, pub fields: Map, + /// The document's ruler unit: what a bare number such as `12` means in a measurement field. + /// Set from the active document when the dialog is shown or confirmed (picas without one). + #[serde(skip)] + pub unit: Unit, } impl Dialog { @@ -75,7 +79,7 @@ impl Dialog { } } } - Dialog { id: id.into(), fields } + Dialog { id: id.into(), fields, unit: Unit::Picas } } fn s(&self, k: &str) -> String { match self.fields.get(k) { @@ -87,7 +91,7 @@ impl Dialog { fn m(&self, k: &str) -> Option { match self.fields.get(k) { Some(Value::Number(n)) => n.as_f64(), - Some(Value::String(s)) => parse_measure(s, Unit::Picas).ok(), + Some(Value::String(s)) => parse_measure(s, self.unit).ok(), _ => None, } } @@ -589,6 +593,50 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result Unit { + app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas) +} + +/// Layout › Create Guides…: guides go on the page in view (or the parent page being edited), not +/// on the first page, and the gutters are typed in the document's ruler unit. +pub fn open_create_guides(app: &mut DesignApp) { + let unit = doc_unit(app); + let Some((spread, page)) = crate::canvas::current_spread_page(app) else { return }; + let gutter = json!(format_measure(12.0, unit)); + app.ui.dialog = Some(Dialog::new( + "createGuides", + json!({"rows": 0, "columns": 0, "rowGutter": gutter, "columnGutter": gutter, "fitTo": "margins", "removeExisting": false, + "spread": serde_json::to_value(spread).unwrap_or(json!(0)), "page": page}), + )); +} + +/// Layout › Margins and Columns…: the margins and columns of the page in view (or of the parent +/// page while parents are edited), shown and typed in the document's ruler unit. +pub fn open_margins_and_columns(app: &mut DesignApp) { + let unit = doc_unit(app); + let parent = crate::canvas::current_parent_page(app); + let cur = crate::canvas::current_page(app).unwrap_or(0); + let Some(st) = app.session.active() else { return }; + let page = match parent { + Some((pi, qi)) => st.doc.parents.get(pi).and_then(|s| s.pages.get(qi)), + None => st.doc.page(cur), + }; + let Some(pg) = page else { return }; + let fm = |v: f64| json!(format_measure(v, unit)); + let mut f = json!({ + "columns": pg.columns.count, "gutter": fm(pg.columns.gutter), + "marginTop": fm(pg.margins.top), "marginBottom": fm(pg.margins.bottom), + "marginInside": fm(pg.margins.inside), "marginOutside": fm(pg.margins.outside), + "adjustLayout": false, "allPages": false, "page": cur, + }); + if let Some((pi, qi)) = parent { + f["parent"] = json!(pi); + f["parentPage"] = json!(qi); + } + app.ui.dialog = Some(Dialog::new("marginsAndColumns", f)); +} + pub fn open_document_setup(app: &mut DesignApp) { let Ok(cur) = app.session.execute("layout.documentSetup", &json!({})) else { return }; let units = app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas); @@ -1116,6 +1164,7 @@ 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 }; + d.unit = doc_unit(app); let mut result: Option = None; let alert_title = d.s("title"); let title = match d.id.as_str() { @@ -1125,6 +1174,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), + "createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"), + "marginsAndColumns" => crate::i18n::tr(&app.ui.language, "Margins and Columns"), "textFrameOptions" => crate::i18n::tr(&app.ui.language, "Text Frame Options"), "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), @@ -1685,6 +1736,61 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); }); } + "createGuides" => { + let u = d.unit.suffix(); + egui::Grid::new("cg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, unit) in [ + ("rows", "Rows: Number", false), + ("rowGutter", "Rows: Gutter", true), + ("columns", "Columns: Number", false), + ("columnGutter", "Columns: Gutter", true), + ] { + let text = crate::i18n::tr(&app.ui.language, label); + ui.label(crate::rtl::widget(ui, if unit { format!("{text} ({u})") } else { text.to_string() })); + text_field(ui, &mut d, key, 80.0); + ui.end_row(); + } + ui.label(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Fit Guides to"))); + combo(ui, &mut d, "fitTo", &[("margins", "Margins"), ("page", "Page")]); + ui.end_row(); + }); + ui.add_space(4.0); + check(ui, &mut d, "removeExisting", crate::i18n::tr(&app.ui.language, "Remove Existing Ruler Guides")); + ui.label(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Applies to the page in view."))); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } + } + "marginsAndColumns" => { + let u = d.unit.suffix(); + egui::Grid::new("mac").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + ui.label(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Columns"))); + text_field(ui, &mut d, "columns", 80.0); + ui.end_row(); + for (key, label) in [ + ("gutter", "Gutter"), + ("marginTop", "Top"), + ("marginBottom", "Bottom"), + ("marginInside", "Inside"), + ("marginOutside", "Outside"), + ] { + // The unit is shown next to every measurement so it is never a guess. + ui.label(crate::rtl::widget(ui, format!("{} ({})", crate::i18n::tr(&app.ui.language, label), u))); + text_field(ui, &mut d, key, 80.0); + ui.end_row(); + } + }); + ui.add_space(4.0); + if d.fields.contains_key("parent") { + ui.label(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Applies to the parent page in view."))); + } else { + check(ui, &mut d, "allPages", crate::i18n::tr(&app.ui.language, "Apply to all pages")); + } + check(ui, &mut d, "adjustLayout", crate::i18n::tr(&app.ui.language, "Adjust Layout")); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } + } "textFrameOptions" => { seed_text_frame_options(app, &mut d); text_frame_options_dialog(app, ui, &mut d); @@ -1743,11 +1849,55 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { - let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; + let Some(mut d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; + d.unit = doc_unit(app); if d.id != "pdfImport" || !d.b("async") { app.cancel_pdf_import(); } match d.id.as_str() { + "createGuides" => { + let (Some(row_gutter), Some(column_gutter)) = + (d.m("rowGutter").filter(|x| *x >= 0.0), d.m("columnGutter").filter(|x| *x >= 0.0)) + else { + let mut d = d.clone(); + d.fields.insert("status".into(), json!("Enter a valid gutter, e.g. 12 or 12 mm.")); + app.ui.dialog = Some(d); + return Err("invalid gutter".into()); + }; + app.run( + "layout.createGuides", + json!({"rows": d.n("rows").unwrap_or(0.0).max(0.0) as u64, "columns": d.n("columns").unwrap_or(0.0).max(0.0) as u64, + "rowGutter": row_gutter, "columnGutter": column_gutter, "fitTo": d.s("fitTo"), + "removeExisting": d.b("removeExisting"), "spread": d.fields.get("spread").cloned().unwrap_or(json!(0)), + "page": d.fields.get("page").cloned().unwrap_or(json!(0))}), + ) + } + "marginsAndColumns" => { + // A value that does not parse is an error shown in the dialog, never a silent 0. + let mut v = Map::new(); + for (k, key) in [("top", "marginTop"), ("bottom", "marginBottom"), ("inside", "marginInside"), ("outside", "marginOutside"), ("gutter", "gutter")] { + match d.m(key).filter(|x| *x >= 0.0) { + Some(x) => { + v.insert(k.into(), json!(x)); + } + None => { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(format!("Enter a valid {k} value, e.g. 12 or 12 mm."))); + app.ui.dialog = Some(d); + return Err(format!("invalid {k}")); + } + } + } + let gutter = v.remove("gutter").unwrap_or(json!(12.0)); + let mut p = json!({"margins": v, "columns": d.n("columns").unwrap_or(1.0).max(1.0) as u64, "gutter": gutter, "adjustLayout": d.b("adjustLayout")}); + if let Some(pi) = d.fields.get("parent").and_then(Value::as_u64) { + p["parent"] = json!(pi); + p["pages"] = json!([d.fields.get("parentPage").and_then(Value::as_u64).unwrap_or(0)]); + } else if !d.b("allPages") { + p["pages"] = json!([d.fields.get("page").and_then(Value::as_u64).unwrap_or(0)]); + } + app.run("layout.marginsAndColumns", p) + } "newDocument" => { let margins = json!({"top": d.m("marginTop").unwrap_or(36.0), "bottom": d.m("marginBottom").unwrap_or(36.0), "inside": d.m("marginInside").unwrap_or(36.0), "outside": d.m("marginOutside").unwrap_or(36.0)}); app.run( diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d123ec4eb..c1fb26cef 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1844,6 +1844,14 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { crate::dialogs::open_print(app); return; } + if params.is_null() && id == "layout.createGuides" { + crate::dialogs::open_create_guides(app); + return; + } + if params.is_null() && id == "layout.marginsAndColumns" { + crate::dialogs::open_margins_and_columns(app); + return; + } if params.is_null() && id == "object.textFrameOptions" { app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); return; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..669a00bdf 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -246,7 +246,14 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // Margins. section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true); let cur = crate::canvas::current_page(app).unwrap_or(0); - if let Some(p) = d.page(cur).cloned() { + // While parent spreads are edited the fields belong to the parent page in view; they used to + // show (and change) the document pages instead, so nothing changed on the parent. + let parent_target = crate::canvas::current_parent_page(app); + let shown = match parent_target { + Some((pi, qi)) => d.parents.get(pi).and_then(|s| s.pages.get(qi)).cloned(), + None => d.page(cur).cloned(), + }; + if let Some(p) = shown { let linked = temp_bool(ui, "margins_linked", true); let b = block(ui, 2.0 * FIELD_H + 5.0); let mut set: Option = None; @@ -289,7 +296,11 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { set_temp_bool(ui, "margins_linked", !linked); } if let Some(m) = set { - let _ = app.run("layout.marginsAndColumns", json!({"margins": m})); + let params = match parent_target { + Some((pi, qi)) => json!({"margins": m, "parent": pi, "pages": [qi]}), + None => json!({"margins": m}), + }; + let _ = app.run("layout.marginsAndColumns", params); } } ui.add_space(6.0); From 227ffecd6fdbed2e15156e01dfd331c9ddd73f3f Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:25:16 +1000 Subject: [PATCH 005/128] WEB1: retain document copies after graphics loss Co-Authored-By: Codex --- apps/designcraft-web/Cargo.toml | 2 +- apps/designcraft-web/src/graphics.rs | 159 +++++++++++++ apps/designcraft-web/src/main.rs | 3 + apps/designcraft-web/src/web.rs | 326 ++++++++++++++++++++++++--- 4 files changed, 462 insertions(+), 28 deletions(-) create mode 100644 apps/designcraft-web/src/graphics.rs diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml index 75775c31e..42cdfce0a 100644 --- a/apps/designcraft-web/Cargo.toml +++ b/apps/designcraft-web/Cargo.toml @@ -29,4 +29,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Event", "EventTarget", "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } diff --git a/apps/designcraft-web/src/graphics.rs b/apps/designcraft-web/src/graphics.rs new file mode 100644 index 000000000..4d9a56d64 --- /dev/null +++ b/apps/designcraft-web/src/graphics.rs @@ -0,0 +1,159 @@ +//! Stop editor work after graphics loss while retaining documents for recovery downloads. + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +use designcraft_engine::{Session, doc::Document}; + +#[derive(Clone, Default)] +pub(crate) struct GraphicsState(Arc); + +impl GraphicsState { + /// Only the first loss should replace the error panel or move keyboard focus. + pub(crate) fn fail(&self) -> bool { + !self.0.swap(true, Ordering::Relaxed) + } + + pub(crate) fn has_failed(&self) -> bool { + self.0.load(Ordering::Relaxed) + } + + pub(crate) fn run_if_healthy(&self, work: impl FnOnce() -> R) -> Option { + if self.has_failed() { None } else { Some(work()) } + } +} + +/// An immutable copy that outlives the session and its renderer. +#[derive(Clone)] +pub(crate) struct RecoveryDocument { + title: String, + doc: Arc, +} + +impl RecoveryDocument { + pub(crate) fn title(&self) -> &str { + &self.title + } + + pub(crate) fn save_copy(&self) -> Result<(String, Vec), String> { + let bytes = designcraft_format::save(&self.doc).map_err(|error| format!("Couldn't save a copy of {}: {error}", self.title))?; + Ok((format!("{}.designcraft", self.doc.title), bytes)) + } +} + +/// Retain all documents without activating one, changing dirty state, or committing a preview. +pub(crate) fn recovery_documents(session: &Session) -> Vec { + session + .documents() + .iter() + .map(|state| RecoveryDocument { title: state.title(), doc: state.preview_stash.as_ref().unwrap_or(&state.doc).clone() }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn recovery_document(session: &Session, index: usize) -> Result<(String, Vec), String> { + recovery_documents(session).get(index).ok_or_else(|| "This document is no longer open.".to_string())?.save_copy() + } + + // Synthetic 1x1 red RGBA PNG. + const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + + #[test] + fn graphics_loss_blocks_editor_work_and_cannot_be_cleared_by_another_callback() { + let graphics = GraphicsState::default(); + let device_callback = graphics.clone(); + let mut session = Session::new(); + session.execute("file.new", &json!({"pages": 1})).unwrap(); + graphics.run_if_healthy(|| session.execute("file.place", &json!({"name": "red.png", "base64": PNG}))).unwrap().unwrap(); + graphics.run_if_healthy(|| session.execute("edit.undo", &json!({}))).unwrap().unwrap(); + let (_, undone) = recovery_document(&session, 0).unwrap(); + assert!(designcraft_engine::cmd::from_bytes(&undone).unwrap().all_items().is_empty()); + graphics.run_if_healthy(|| session.execute("edit.redo", &json!({}))).unwrap().unwrap(); + let (_, before) = recovery_document(&session, 0).unwrap(); + assert_eq!(designcraft_engine::cmd::from_bytes(&before).unwrap().all_items().len(), 1); + + assert!(device_callback.fail()); + assert!(!graphics.fail(), "duplicate context/device notifications must not replace recovery controls"); + assert!(graphics.has_failed()); + assert!(graphics.run_if_healthy(|| session.execute("edit.undo", &json!({}))).is_none()); + assert_eq!(recovery_document(&session, 0).unwrap().1, before, "a hidden editor must not apply more edits"); + } + + #[test] + fn recovery_copies_all_open_documents_without_changing_session_state() { + let mut session = Session::new(); + session.execute("file.new", &json!({"title": "First", "pages": 1})).unwrap(); + session.execute("file.place", &json!({"name": "red.png", "base64": PNG})).unwrap(); + let first = session.doc().unwrap().doc.clone(); + session.execute("file.new", &json!({"title": "Second", "pages": 1})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 110, 110]})).unwrap(); + let second = session.doc().unwrap().doc.clone(); + let active = session.active_index(); + let journal_len = session.journal.len(); + + for (index, expected) in [first.clone(), second.clone()].iter().enumerate() { + let (name, bytes) = recovery_document(&session, index).unwrap(); + assert_eq!(name, format!("{}.designcraft", expected.title)); + let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap(); + assert_eq!(reopened, **expected, "the copy must retain document content and embedded PNG bytes"); + } + assert_eq!(session.active_index(), active); + assert_eq!(session.journal.len(), journal_len); + assert!(Arc::ptr_eq(&session.documents()[0].doc, &first)); + assert!(Arc::ptr_eq(&session.documents()[1].doc, &second)); + assert!(session.documents().iter().all(|state| state.is_dirty())); + assert!(recovery_document(&session, 2).is_err()); + } + + #[test] + fn recovery_saves_the_template_during_data_merge_preview() { + let mut session = Session::new(); + session.execute("file.new", &json!({"title": "Template", "pages": 1})).unwrap(); + let template = session.doc().unwrap().doc.clone(); + session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap(); + session.doc_mut().unwrap().preview_stash = Some(template.clone()); + let (_, bytes) = recovery_document(&session, 0).unwrap(); + let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap(); + assert_eq!(reopened, *template); + assert!(Arc::ptr_eq(session.doc().unwrap().preview_stash.as_ref().unwrap(), &template)); + } + + #[test] + fn recovery_snapshots_keep_all_documents_and_assets_after_session_drop() { + let mut session = Session::new(); + session.execute("file.new", &json!({"title": "First", "pages": 1})).unwrap(); + session.execute("file.place", &json!({"name": "red.png", "base64": PNG})).unwrap(); + let first = session.doc().unwrap().doc.clone(); + session.execute("file.new", &json!({"title": "Template", "pages": 1})).unwrap(); + session.execute("file.place", &json!({"name": "template.png", "base64": PNG})).unwrap(); + let template = session.doc().unwrap().doc.clone(); + session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap(); + session.doc_mut().unwrap().preview_stash = Some(template.clone()); + + let documents = recovery_documents(&session); + assert_eq!(documents.len(), 2); + assert_eq!(documents[0].title(), "First"); + assert_eq!(documents[1].title(), "Template"); + assert!(Arc::ptr_eq(&documents[0].doc, &first)); + assert!(Arc::ptr_eq(&documents[1].doc, &template)); + session.stop_data_preview(); + session.execute("frame.create", &json!({"rect": [10, 10, 110, 110]})).unwrap(); + drop(session); + + for (snapshot, expected) in documents.iter().zip([first, template]) { + let (name, bytes) = snapshot.save_copy().unwrap(); + assert_eq!(name, format!("{}.designcraft", expected.title)); + assert!(bytes.starts_with(b"PK")); + let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap(); + assert_eq!(reopened, *expected, "snapshots must retain every document and its embedded asset bytes"); + assert!(!reopened.assets.is_empty()); + assert!(reopened.assets.values().all(|asset| !asset.data.is_empty())); + } + } +} diff --git a/apps/designcraft-web/src/main.rs b/apps/designcraft-web/src/main.rs index 527baff47..1b28064a9 100644 --- a/apps/designcraft-web/src/main.rs +++ b/apps/designcraft-web/src/main.rs @@ -22,6 +22,9 @@ mod browser_policy; #[cfg(target_arch = "wasm32")] mod web; +#[cfg(any(target_arch = "wasm32", test))] +mod graphics; + #[cfg(target_arch = "wasm32")] fn main() { web::start(); diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index fe7cca4ae..c532b5dca 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -1,6 +1,7 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. use super::browser_policy::{has_query_flag, mime_for}; +use crate::graphics::{GraphicsState, RecoveryDocument, recovery_documents}; use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, ImportRequest, ImportedFile, Inbox, Services}; use wasm_bindgen::JsCast as _; @@ -37,8 +38,16 @@ pub fn start() { log::error!("no document"); return; }; + if let Err(e) = create_error_panel(&document) { + log::error!("couldn't create graphics error panel: {e:?}"); + if let Some(el) = document.get_element_by_id(LOADING_ID) { + el.set_text_content(Some("DesignCraft couldn't start. Reload this page to try again.")); + } + return; + } + let graphics = GraphicsState::default(); let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else { - log::error!("missing "); + graphics_failed(&graphics, "DesignCraft failed to start", "Reload this page to try again.", &format!("Missing canvas {CANVAS_ID}.")); return; }; let mut options = eframe::WebOptions::default(); @@ -47,32 +56,285 @@ pub fn start() { { create.instance_descriptor.backends = eframe::wgpu::Backends::GL; } - let result = eframe::WebRunner::new() + let runner = eframe::WebRunner::new(); + let creator_graphics = graphics.clone(); + let result = runner .start( - canvas, + canvas.clone(), options, Box::new(move |cc| { if let Some(rs) = &cc.wgpu_render_state { log::info!("designcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend); + let device_graphics = creator_graphics.clone(); + // This also observes an already-lost WebGPU device through its lost promise. + rs.device.set_device_lost_callback(move |reason, message| { + graphics_failed(&device_graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, &format!("Device loss ({reason:?}): {message}")); + }); } let inbox: Inbox = Inbox::default(); let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); if has_query_flag(&query(), "sample") { let _ = app.run("file.newSample", serde_json::json!({})); } - Ok(Box::new(WebShell { app, inbox })) + Ok(Box::new(WebShell { app, inbox, graphics: creator_graphics })) }), ) .await; - if let Some(el) = document.get_element_by_id(LOADING_ID) { - match result { - Ok(()) => el.remove(), - Err(e) => el.set_inner_html(&format!("

DesignCraft failed to start: {e:?}

A browser with WebGPU or WebGL2 is required.

")), + match result { + Ok(()) => { + if let Err(e) = install_recovery_controls(&runner, &canvas, &graphics) { + graphics_failed(&graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, &format!("Couldn't install graphics recovery controls: {e:?}")); + if let Some(shell) = runner.app_mut::() + && let Err(e) = prepare_document_recovery(&runner, &shell.app.session) + { + recovery_controls_failed(&e); + } + } + // A loss callback can run during startup. Never hide its failure panel. + if !graphics.has_failed() + && let Some(el) = document.get_element_by_id(LOADING_ID) + { + el.remove(); + } + } + Err(e) => { + graphics_failed( + &graphics, + "DesignCraft failed to start", + "Reload this page to try again. A browser with WebGPU or WebGL2 is required.", + &format!("{e:?}"), + ); + if let Some(shell) = runner.app_mut::() + && let Err(e) = prepare_document_recovery(&runner, &shell.app.session) + { + recovery_controls_failed(&e); + } } } }); } +const ERROR_ID: &str = "designcraft_graphics_error"; +const ERROR_TITLE_ID: &str = "designcraft_graphics_title"; +const ERROR_MESSAGE_ID: &str = "designcraft_graphics_message"; +const ERROR_DETAIL_ID: &str = "designcraft_graphics_detail"; +const ERROR_ACTIONS_ID: &str = "designcraft_graphics_actions"; +const ERROR_STATUS_ID: &str = "designcraft_graphics_status"; +const ERROR_DOCUMENTS_ID: &str = "designcraft_graphics_documents"; +const GRAPHICS_LOST_EVENT: &str = "designcraftgraphicslost"; +const GRAPHICS_TITLE: &str = "Graphics connection lost"; +const GRAPHICS_MESSAGE: &str = "Editing has stopped. Save a copy of each open document, then reload this page and reopen the copies. Reloading discards changes you have not downloaded."; + +fn create_error_panel(document: &web_sys::Document) -> Result<(), wasm_bindgen::JsValue> { + let panel: web_sys::HtmlElement = document.create_element("div")?.dyn_into()?; + panel.set_id(ERROR_ID); + panel.set_tab_index(-1); + panel.set_attribute("role", "alertdialog")?; + panel.set_attribute("aria-modal", "true")?; + panel.set_attribute("aria-labelledby", ERROR_TITLE_ID)?; + panel.set_attribute("aria-describedby", ERROR_MESSAGE_ID)?; + // DOM content remains visible when the GPU can no longer paint the egui canvas. + let colors = designcraft_ui_egui::theme::Tokens::for_brightness(designcraft_ui_egui::theme::Brightness::Dark); + panel.style().set_css_text(&format!( + "position:fixed;inset:0;z-index:1;display:none;flex-direction:column;align-items:center;justify-content:safe center;box-sizing:border-box;padding:24px;overflow:auto;background:{};color:{};font:14px system-ui,sans-serif;gap:12px;", + css_color(colors.app_bar), css_color(colors.text), + )); + for (tag, id) in [("h1", ERROR_TITLE_ID), ("p", ERROR_MESSAGE_ID), ("div", ERROR_ACTIONS_ID), ("p", ERROR_STATUS_ID), ("pre", ERROR_DETAIL_ID)] { + let child: web_sys::HtmlElement = document.create_element(tag)?.dyn_into()?; + child.set_id(id); + child.style().set_css_text("margin:0;max-width:640px;white-space:pre-wrap;overflow-wrap:anywhere;"); + if id == ERROR_ACTIONS_ID { + child.style().set_css_text("display:flex;flex-direction:column;gap:8px;max-width:100%;"); + add_reload_control(document, &child)?; + } else if id == ERROR_STATUS_ID { + child.set_attribute("aria-live", "polite")?; + } + panel.append_child(&child)?; + } + document.body().ok_or_else(|| wasm_bindgen::JsValue::from_str("no body"))?.append_child(&panel)?; + Ok(()) +} + +fn graphics_failed(graphics: &GraphicsState, title: &str, message: &str, detail: &str) { + if !graphics.fail() { + return; + } + log::error!("designcraft-web: {detail}"); + if let Err(e) = show_error(title, message, detail) { + log::error!("couldn't show graphics error: {e:?}"); + } + // A device callback may run while AppRunner is borrowed. Defer its notification; + // the WebGL listener already holding AppRunner captures documents directly. + if let Some(window) = web_sys::window() + && let Some(canvas) = window.document().and_then(|doc| doc.get_element_by_id(CANVAS_ID)) + { + let notify = wasm_bindgen::closure::Closure::once_into_js(move || { + if let Err(e) = web_sys::Event::new(GRAPHICS_LOST_EVENT).and_then(|event| canvas.dispatch_event(&event)) { + recovery_controls_failed(&e); + } + }); + window.queue_microtask(notify.unchecked_ref()); + } +} + +fn show_error(title: &str, message: &str, detail: &str) -> Result<(), wasm_bindgen::JsValue> { + let document = web_sys::window().and_then(|w| w.document()).ok_or_else(|| wasm_bindgen::JsValue::from_str("no document"))?; + let panel: web_sys::HtmlElement = + document.get_element_by_id(ERROR_ID).ok_or_else(|| wasm_bindgen::JsValue::from_str("no graphics error panel"))?.dyn_into()?; + for (id, text) in [(ERROR_TITLE_ID, title), (ERROR_MESSAGE_ID, message), (ERROR_DETAIL_ID, detail)] { + if let Some(el) = document.get_element_by_id(id) { + el.set_text_content(Some(text)); + } + } + panel.style().set_property("display", "flex")?; + if let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|el| el.dyn_into::().ok()) { + canvas.style().set_property("visibility", "hidden")?; + } + if let Some(loading) = document.get_element_by_id(LOADING_ID).and_then(|el| el.dyn_into::().ok()) { + loading.style().set_property("display", "none")?; + } + // Include eframe's hidden text input: neither pointer nor keyboard focus may reach the editor. + let mut sibling = document.body().and_then(|body| body.first_element_child()); + while let Some(el) = sibling { + sibling = el.next_element_sibling(); + if el.id() != ERROR_ID { + if let Some(html) = el.dyn_ref::() { + html.set_inert(true); + } + el.set_attribute("aria-hidden", "true")?; + } + } + panel.focus()?; + Ok(()) +} + +/// Recovery listeners are owned by their DOM targets, independently of WebRunner. +fn add_dom_event_listener( + target: &web_sys::EventTarget, + event_name: &str, + callback: impl FnMut(web_sys::Event) + 'static, +) -> Result<(), wasm_bindgen::JsValue> { + let callback = wasm_bindgen::closure::Closure::wrap(Box::new(callback) as Box); + target.add_event_listener_with_callback(event_name, callback.as_ref().unchecked_ref())?; + // EventTarget retains the JS function; weak references reclaim its captures with the target. + let _ = callback.into_js_value(); + Ok(()) +} + +fn css_color(color: egui::Color32) -> String { + format!("#{:02x}{:02x}{:02x}", color.r(), color.g(), color.b()) +} + +fn recovery_button(document: &web_sys::Document) -> Result { + let button: web_sys::HtmlElement = document.create_element("button")?.dyn_into()?; + button.set_attribute("type", "button")?; + let colors = designcraft_ui_egui::theme::Tokens::for_brightness(designcraft_ui_egui::theme::Brightness::Dark); + button.style().set_css_text(&format!( + "box-sizing:border-box;max-width:100%;padding:6px 12px;border:1px solid {};border-radius:{}px;background:{};color:{};font:inherit;white-space:normal;overflow-wrap:anywhere;cursor:pointer;", + css_color(colors.field_border), colors.radius, css_color(colors.button), css_color(colors.text), + )); + Ok(button) +} + +fn add_reload_control(document: &web_sys::Document, actions: &web_sys::HtmlElement) -> Result<(), wasm_bindgen::JsValue> { + let reload = recovery_button(document)?; + reload.set_text_content(Some("Reload")); + add_dom_event_listener(&reload, "click", move |event| { + event.stop_propagation(); + if let Some(window) = web_sys::window() + && let Err(e) = window.location().reload() + { + log::error!("couldn't reload: {e:?}"); + } + })?; + actions.append_child(&reload)?; + Ok(()) +} + +fn install_recovery_controls( + runner: &eframe::WebRunner, + canvas: &web_sys::HtmlCanvasElement, + graphics: &GraphicsState, +) -> Result<(), wasm_bindgen::JsValue> { + let loss_runner = runner.clone(); + runner.add_event_listener(canvas, GRAPHICS_LOST_EVENT, move |_: web_sys::Event, app_runner| { + let shell = app_runner.app_mut::(); + if shell.graphics.has_failed() + && let Err(e) = prepare_document_recovery(&loss_runner, &shell.app.session) + { + recovery_controls_failed(&e); + } + })?; + let context_graphics = graphics.clone(); + let context_runner = runner.clone(); + // wgpu's GLES backend does not observe browser WebGL context-loss events. + runner.add_event_listener(canvas, "webglcontextlost", move |_: web_sys::Event, app_runner| { + graphics_failed(&context_graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, "The browser lost the WebGL graphics context."); + if let Err(e) = prepare_document_recovery(&context_runner, &app_runner.app_mut::().app.session) { + recovery_controls_failed(&e); + } + })?; + + // A WebGPU device can be lost while start() is still awaiting initialization. + if graphics.has_failed() + && let Some(shell) = runner.app_mut::() + { + prepare_document_recovery(runner, &shell.app.session)?; + } + Ok(()) +} + +fn prepare_document_recovery(runner: &eframe::WebRunner, session: &Session) -> Result<(), wasm_bindgen::JsValue> { + let window = web_sys::window().ok_or_else(|| wasm_bindgen::JsValue::from_str("no window"))?; + let documents = recovery_documents(session); + if add_document_recovery_controls(documents)? { + // Controls now own the snapshots. Release AppRunner's borrow before stopping painting + // and removing its listeners/ResizeObserver; never discard documents on setup failure. + let runner = runner.clone(); + let stop = wasm_bindgen::closure::Closure::once_into_js(move || runner.destroy()); + window.queue_microtask(stop.unchecked_ref()); + } + Ok(()) +} + +fn recovery_controls_failed(error: &wasm_bindgen::JsValue) { + log::error!("couldn't prepare recovery downloads: {error:?}"); + if let Some(status) = web_sys::window().and_then(|w| w.document()).and_then(|doc| doc.get_element_by_id(ERROR_STATUS_ID)) { + status.set_text_content(Some("Couldn't prepare document downloads. Keep this page open to preserve unsaved documents.")); + } +} + +fn add_document_recovery_controls(snapshots: Vec) -> Result { + let document = web_sys::window().and_then(|w| w.document()).ok_or_else(|| wasm_bindgen::JsValue::from_str("no document"))?; + let actions = document.get_element_by_id(ERROR_ACTIONS_ID).ok_or_else(|| wasm_bindgen::JsValue::from_str("no recovery actions"))?; + if document.get_element_by_id(ERROR_DOCUMENTS_ID).is_some() { + return Ok(false); + } + let documents: web_sys::HtmlElement = document.create_element("div")?.dyn_into()?; + documents.set_id(ERROR_DOCUMENTS_ID); + documents.style().set_css_text("display:flex;flex-direction:column;gap:8px;max-width:100%;"); + for snapshot in snapshots { + let button = recovery_button(&document)?; + button.set_text_content(Some(&format!("Save a Copy: {}", snapshot.title()))); + add_dom_event_listener(&button, "click", move |event| { + event.stop_propagation(); + let result = snapshot.save_copy().and_then(|(name, bytes)| { + download(&name, &bytes)?; + Ok(format!("Requested download of {name}. Check your browser downloads before reloading.")) + }); + let text = result.unwrap_or_else(|e| e); + if let Some(status) = web_sys::window().and_then(|w| w.document()).and_then(|doc| doc.get_element_by_id(ERROR_STATUS_ID)) { + status.set_text_content(Some(&text)); + } + })?; + documents.append_child(&button)?; + } + // Build off-DOM so a setup error leaves the session intact and permits a clean retry. + // The inserted element's id also prevents duplicate buttons on repeated loss notifications. + actions.insert_before(&documents, actions.first_child().as_ref())?; + Ok(true) +} + fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } @@ -82,36 +344,46 @@ fn query() -> String { struct WebShell { app: DesignApp, inbox: Inbox, + graphics: GraphicsState, } impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - for f in dropped { - let request = self.app.import_request("drop"); - let inbox = self.inbox.clone(); - let ctx = ctx.clone(); - wasm_bindgen_futures::spawn_local(async move { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - match f.bytes_async().await { - Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes }); - ctx.request_repaint(); + let graphics = self.graphics.clone(); + let _ = graphics.run_if_healthy(|| { + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + for f in dropped { + let request = self.app.import_request("drop"); + let inbox = self.inbox.clone(); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); + match f.bytes_async().await { + Ok(bytes) => { + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes }); + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), - } - }); - } - self.app.logic(ctx); + }); + } + self.app.logic(ctx); + }); } 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); + let _ = self.graphics.run_if_healthy(|| { + designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused()); + self.app.raw_input_hook(raw); + }); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.app.ui(ui); + let _ = self.graphics.run_if_healthy(|| self.app.ui(ui)); + } + + fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { + Some(self) } } From 5f535ef60f87b03dd69a65d9e64e6322e4816331 Mon Sep 17 00:00:00 2001 From: Middle Disc Date: Sun, 11 Oct 2026 21:34:03 +0330 Subject: [PATCH 006/128] Layout: explicit target for Margins and Columns and Create Guides Only these two dialogs read bare numbers in the ruler unit; other dialogs keep their meaning. Apply to: this page, both pages of the spread, or all pages. Parent edits default to the whole parent spread. Replace unchecked indexing in inherited_guides and marginsAndColumns with get(). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01XWRYQ5eieuUrtxhdbh6goq --- crates/doc/src/build.rs | 2 +- crates/engine/src/cmd/layout.rs | 13 +- crates/ui-egui/src/dialogs.rs | 179 ++++++++++++++++++++---- crates/ui-egui/src/panels/properties.rs | 6 +- 4 files changed, 168 insertions(+), 32 deletions(-) diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 33256d338..b6fd5d9b6 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -559,7 +559,7 @@ impl Document { /// happens on the parent page. pub fn inherited_guides(&self, abs: usize) -> Vec { let (Some(page), Some((pi, qi))) = (self.page(abs), self.parent_page_for(abs)) else { return Vec::new() }; - let Some(parent) = self.parents[pi].pages.get(qi) else { return Vec::new() }; + let Some(parent) = self.parents.get(pi).and_then(|ps| ps.pages.get(qi)) else { return Vec::new() }; let dx = page.x - parent.x; parent .guides diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 7df688e5f..b06ba9eb3 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -229,9 +229,12 @@ pub fn specs() -> Vec { let idxs: Vec = pages.clone().unwrap_or_else(|| (0..ps.pages.len()).collect()); let old: Vec<(usize, Margins, designcraft_doc::Columns)> = idxs.iter().filter_map(|&qi| ps.pages.get(qi).map(|pg| (qi, pg.margins, pg.columns.clone()))).collect(); - for &qi in &idxs { - if let Some(pg) = std::sync::Arc::make_mut(&mut d.parents[pi]).pages.get_mut(qi) { - set_margins_columns(pg, &p); + if let Some(ps) = d.parents.get_mut(pi) { + let ps = std::sync::Arc::make_mut(ps); + for &qi in &idxs { + if let Some(pg) = ps.pages.get_mut(qi) { + set_margins_columns(pg, &p); + } } } for abs in 0..d.page_count() { @@ -241,7 +244,7 @@ pub fn specs() -> Vec { } let Some((_, om, oc)) = old.iter().find(|o| o.0 == qi) else { continue }; let Some((si, pj)) = d.page_loc(abs) else { continue }; - let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pj]; + let Some(pg) = d.spreads.get_mut(si).and_then(|sp| std::sync::Arc::make_mut(sp).pages.get_mut(pj)) else { continue }; if pg.margins == *om && pg.columns == *oc { set_margins_columns(pg, &p); } @@ -250,7 +253,7 @@ pub fn specs() -> Vec { let all: Vec = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect()); for abs in all { let Some((si, pi)) = d.page_loc(abs) else { continue }; - let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; + let Some(pg) = d.spreads.get_mut(si).and_then(|sp| std::sync::Arc::make_mut(sp).pages.get_mut(pi)) else { continue }; set_margins_columns(pg, &p); } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ab616504c..2a0c39532 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,6 +1,7 @@ //! Modal dialogs. Each dialog keeps its fields in a JSON map so the control channel can set them //! (`ui.dialog.set {field, value}`) and confirm them (`ui.dialog.confirm`) like a user would. +use designcraft_doc::SpreadRef; use designcraft_geom::{Unit, format_measure, units::parse_measure}; use serde::Serialize; use serde_json::{Map, Value, json}; @@ -603,16 +604,37 @@ fn doc_unit(app: &DesignApp) -> Unit { pub fn open_create_guides(app: &mut DesignApp) { let unit = doc_unit(app); let Some((spread, page)) = crate::canvas::current_spread_page(app) else { return }; + let editing_parents = matches!(spread, SpreadRef::Parent(_)); let gutter = json!(format_measure(12.0, unit)); - app.ui.dialog = Some(Dialog::new( + let mut d = Dialog::new( "createGuides", json!({"rows": 0, "columns": 0, "rowGutter": gutter, "columnGutter": gutter, "fitTo": "margins", "removeExisting": false, - "spread": serde_json::to_value(spread).unwrap_or(json!(0)), "page": page}), - )); + "spread": serde_json::to_value(spread).unwrap_or(json!(0)), "page": page, + // A parent spread is edited as a whole (both pages of a facing spread), so the guides show on every page that uses it. + "target": if editing_parents { "spread" } else { "page" }, + "where": target_description(app, spread, page)}), + ); + d.unit = unit; + app.ui.dialog = Some(d); +} + +/// "Page 4" / "Pages 4–5" / "Parent A-Parent": what a dialog's target choice refers to. +fn target_description(app: &DesignApp, spread: SpreadRef, page: usize) -> String { + let Some(st) = app.session.active() else { return String::new() }; + match spread { + SpreadRef::Doc(si) => { + let first = st.doc.first_page_of_spread(si); + format!("page {}", app.session.page_label(first + page)) + } + SpreadRef::Parent(pi) => { + let name = st.doc.parents.get(pi).and_then(|p| p.parent.as_ref()).map(|i| i.label()).unwrap_or_default(); + format!("parent {name}") + } + } } /// Layout › Margins and Columns…: the margins and columns of the page in view (or of the parent -/// page while parents are edited), shown and typed in the document's ruler unit. +/// spread while parents are edited), shown and typed in the document's ruler unit. pub fn open_margins_and_columns(app: &mut DesignApp) { let unit = doc_unit(app); let parent = crate::canvas::current_parent_page(app); @@ -624,17 +646,53 @@ pub fn open_margins_and_columns(app: &mut DesignApp) { }; let Some(pg) = page else { return }; let fm = |v: f64| json!(format_measure(v, unit)); + // The pages of the spread in view: both pages of a facing spread. + let spread_pages: Vec = match parent { + Some((pi, _)) => (0..st.doc.parents.get(pi).map_or(0, |s| s.pages.len())).collect(), + None => st.doc.page_loc(cur).map_or_else(Vec::new, |(si, _)| { + let first = st.doc.first_page_of_spread(si); + (first..first + st.doc.spreads.get(si).map_or(0, |s| s.pages.len())).collect() + }), + }; + let where_ = match parent { + Some((pi, qi)) => target_description(app, SpreadRef::Parent(pi), qi), + None => format!("page {}", app.session.page_label(cur)), + }; let mut f = json!({ "columns": pg.columns.count, "gutter": fm(pg.columns.gutter), "marginTop": fm(pg.margins.top), "marginBottom": fm(pg.margins.bottom), "marginInside": fm(pg.margins.inside), "marginOutside": fm(pg.margins.outside), - "adjustLayout": false, "allPages": false, "page": cur, + "adjustLayout": false, "page": cur, "spreadPages": spread_pages, + "target": if parent.is_some() { "spread" } else { "page" }, "where": where_, }); if let Some((pi, qi)) = parent { f["parent"] = json!(pi); f["parentPage"] = json!(qi); } - app.ui.dialog = Some(Dialog::new("marginsAndColumns", f)); + let mut d = Dialog::new("marginsAndColumns", f); + d.unit = unit; + app.ui.dialog = Some(d); +} + +/// "Apply to:" choice of the Create Guides and Margins and Columns dialogs, with the page it +/// refers to spelled out so a wrong target is visible before the dialog is confirmed. +fn target_row(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, all_pages: bool) { + let lang = app.ui.language.clone(); + let tr = |s: &'static str| crate::i18n::tr(&lang, s); + let parent = d.fields.contains_key("parent") || matches!(d.fields.get("spread"), Some(v) if v["kind"] == "parent"); + let spread_label = if parent { tr("Both pages of the parent spread") } else { tr("Both pages of the spread") }; + let mut opts = vec![("page", tr("This page")), ("spread", spread_label)]; + if all_pages { + opts.push(("all", tr("All pages"))); + } + ui.horizontal(|ui| { + ui.label(crate::rtl::widget(ui, tr("Apply to:"))); + combo(ui, d, "target", &opts); + }); + let w = d.s("where"); + if !w.is_empty() { + ui.label(egui::RichText::new(format!("{} {w}", tr("This page is"))).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } } pub fn open_document_setup(app: &mut DesignApp) { @@ -1164,7 +1222,6 @@ 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 }; - d.unit = doc_unit(app); let mut result: Option = None; let alert_title = d.s("title"); let title = match d.id.as_str() { @@ -1756,7 +1813,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); ui.add_space(4.0); check(ui, &mut d, "removeExisting", crate::i18n::tr(&app.ui.language, "Remove Existing Ruler Guides")); - ui.label(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Applies to the page in view."))); + target_row(app, ui, &mut d, false); if let Some(st) = d.fields.get("status").and_then(Value::as_str) { ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); } @@ -1781,11 +1838,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } }); ui.add_space(4.0); - if d.fields.contains_key("parent") { - ui.label(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Applies to the parent page in view."))); - } else { - check(ui, &mut d, "allPages", crate::i18n::tr(&app.ui.language, "Apply to all pages")); - } + let all_pages = !d.fields.contains_key("parent"); + target_row(app, ui, &mut d, all_pages); check(ui, &mut d, "adjustLayout", crate::i18n::tr(&app.ui.language, "Adjust Layout")); if let Some(st) = d.fields.get("status").and_then(Value::as_str) { ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); @@ -1849,8 +1903,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { - let Some(mut d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; - d.unit = doc_unit(app); + let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; if d.id != "pdfImport" || !d.b("async") { app.cancel_pdf_import(); } @@ -1864,13 +1917,14 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.ui.dialog = Some(d); return Err("invalid gutter".into()); }; - app.run( - "layout.createGuides", - json!({"rows": d.n("rows").unwrap_or(0.0).max(0.0) as u64, "columns": d.n("columns").unwrap_or(0.0).max(0.0) as u64, - "rowGutter": row_gutter, "columnGutter": column_gutter, "fitTo": d.s("fitTo"), - "removeExisting": d.b("removeExisting"), "spread": d.fields.get("spread").cloned().unwrap_or(json!(0)), - "page": d.fields.get("page").cloned().unwrap_or(json!(0))}), - ) + let mut p = json!({"rows": d.n("rows").unwrap_or(0.0).max(0.0) as u64, "columns": d.n("columns").unwrap_or(0.0).max(0.0) as u64, + "rowGutter": row_gutter, "columnGutter": column_gutter, "fitTo": d.s("fitTo"), + "removeExisting": d.b("removeExisting"), "spread": d.fields.get("spread").cloned().unwrap_or(json!(0))}); + // Without `page` the command covers every page of the spread. + if d.s("target") != "spread" { + p["page"] = d.fields.get("page").cloned().unwrap_or(json!(0)); + } + app.run("layout.createGuides", p) } "marginsAndColumns" => { // A value that does not parse is an error shown in the dialog, never a silent 0. @@ -1890,11 +1944,16 @@ pub fn confirm(app: &mut DesignApp) -> Result { } let gutter = v.remove("gutter").unwrap_or(json!(12.0)); let mut p = json!({"margins": v, "columns": d.n("columns").unwrap_or(1.0).max(1.0) as u64, "gutter": gutter, "adjustLayout": d.b("adjustLayout")}); + let spread_pages: Vec = d.fields.get("spreadPages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); if let Some(pi) = d.fields.get("parent").and_then(Value::as_u64) { p["parent"] = json!(pi); - p["pages"] = json!([d.fields.get("parentPage").and_then(Value::as_u64).unwrap_or(0)]); - } else if !d.b("allPages") { - p["pages"] = json!([d.fields.get("page").and_then(Value::as_u64).unwrap_or(0)]); + p["pages"] = if d.s("target") == "page" { json!([d.fields.get("parentPage").and_then(Value::as_u64).unwrap_or(0)]) } else { json!(spread_pages) }; + } else { + match d.s("target").as_str() { + "all" => {} + "spread" if !spread_pages.is_empty() => p["pages"] = json!(spread_pages), + _ => p["pages"] = json!([d.fields.get("page").and_then(Value::as_u64).unwrap_or(0)]), + } } app.run("layout.marginsAndColumns", p) } @@ -4713,4 +4772,74 @@ mod tests { 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"); } + #[test] + fn margins_dialog_reads_bare_numbers_in_the_ruler_unit() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": "millimeters"})).unwrap(); + crate::menus::activate(&mut app, "layout.marginsAndColumns", &Value::Null); + for k in ["marginTop", "marginBottom", "marginInside", "marginOutside"] { + ctl(&mut app, "ui.dialog.set", json!({"field": k, "value": "15"})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let top = app.session.active().unwrap().doc.page(0).unwrap().margins.top; + assert!((top - 15.0 * 72.0 / 25.4).abs() < 0.01, "top margin was {top} pt"); + } + fn facing_app(pages: u64) -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": pages, "facingPages": true})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + app + } + + fn guide_counts(app: &DesignApp) -> Vec { + let doc = &app.session.active().unwrap().doc; + (0..doc.page_count()).map(|abs| doc.page(abs).unwrap().guides.len()).collect() + } + + #[test] + fn create_guides_dialog_targets_the_page_in_view() { + let mut app = facing_app(6); + crate::canvas::go_to_page(&mut app, 4); + crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); + ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "3"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(guide_counts(&app), vec![0, 0, 0, 0, 4, 0]); + // "Both pages of the spread" covers the facing pair. + crate::canvas::go_to_page(&mut app, 1); + crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); + ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "2"})); + ctl(&mut app, "ui.dialog.set", json!({"field": "target", "value": "spread"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 4, 0]); + } + + #[test] + fn parent_dialogs_cover_both_pages_of_the_parent_spread_by_default() { + let mut app = facing_app(4); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + crate::menus::activate(&mut app, "layout.marginsAndColumns", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("target"), "spread"); + for k in ["marginTop", "marginBottom", "marginInside", "marginOutside"] { + ctl(&mut app, "ui.dialog.set", json!({"field": k, "value": "20 pt"})); + } + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + crate::menus::activate(&mut app, "layout.createGuides", &Value::Null); + ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": "2"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let doc = &app.session.active().unwrap().doc; + for pg in &doc.parents[0].pages { + assert_eq!((pg.margins.top, pg.margins.outside, pg.guides.len()), (20.0, 20.0, 2)); + } + // Every document page shows the parent's guides. + assert!((0..doc.page_count()).all(|abs| doc.inherited_guides(abs).len() == 2)); + } + + #[test] + fn other_dialogs_keep_reading_bare_numbers_as_picas() { + let mut app = facing_app(2); + app.run("document.preferences", json!({"horizontalUnits": "millimeters"})).unwrap(); + app.ui.dialog = Some(Dialog::new("newDocument", json!({"width": "15"}))); + assert_eq!(app.ui.dialog.as_ref().unwrap().m("width"), Some(180.0)); + } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 669a00bdf..a250eaceb 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -297,7 +297,11 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { } if let Some(m) = set { let params = match parent_target { - Some((pi, qi)) => json!({"margins": m, "parent": pi, "pages": [qi]}), + // Both pages of a facing parent spread, so the margins show wherever the parent is used. + Some((pi, _)) => { + let n = app.session.active().and_then(|st| st.doc.parents.get(pi)).map_or(1, |sp| sp.pages.len()); + json!({"margins": m, "parent": pi, "pages": (0..n).collect::>()}) + } None => json!({"margins": m}), }; let _ = app.run("layout.marginsAndColumns", params); From 401fe3bb67dcb4a7c1bdac257b4de2b855d7fa69 Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 10:57:59 -0400 Subject: [PATCH 007/128] Engine: check automation read and write roots on every path a command reads or writes --- crates/engine/src/cmd/book.rs | 10 +- crates/engine/src/cmd/colorsettings.rs | 3 +- crates/engine/src/cmd/datamerge/fill.rs | 4 +- crates/engine/src/cmd/datamerge/mod.rs | 10 +- crates/engine/src/cmd/datamerge/parse.rs | 2 +- crates/engine/src/cmd/export.rs | 10 +- crates/engine/src/cmd/file.rs | 73 +++- crates/engine/src/cmd/interchange.rs | 16 +- crates/engine/src/cmd/library.rs | 4 +- crates/engine/src/cmd/links.rs | 12 +- crates/engine/src/cmd/package.rs | 2 + crates/engine/src/cmd/style.rs | 8 +- crates/engine/src/cmd/xml.rs | 4 +- crates/engine/src/file_access.rs | 515 +++++++++++++++++++++++ crates/engine/src/io.rs | 71 ++++ crates/engine/src/lib.rs | 4 + crates/engine/src/tests_roots.rs | 212 ++++++++++ 17 files changed, 900 insertions(+), 60 deletions(-) create mode 100644 crates/engine/src/file_access.rs create mode 100644 crates/engine/src/io.rs create mode 100644 crates/engine/src/tests_roots.rs diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index fbf80ed9e..58edc6aa4 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -28,7 +28,7 @@ fn no_book() -> EngineError { #[cfg(not(target_arch = "wasm32"))] fn save(b: &Book) -> Result<()> { let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?; - crate::atomic::write_file(&b.path, &bytes) + crate::io::write_file(&b.path, &bytes).map_err(EngineError::Other) } #[cfg(target_arch = "wasm32")] @@ -38,14 +38,14 @@ fn save(_: &Book) -> Result<()> { fn load(path: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let bytes: Vec = { let _ = path; vec![] }; if path.to_lowercase().ends_with(".idml") { - return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); + return super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); } super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -248,7 +248,7 @@ pub fn specs() -> Vec { cmd!(noundo "book.open", "Open Book", [], None, "{path} → {documents}", always, |s, p| { let path = str_param(p, "path").ok_or_else(|| bad("book.open", "`path` required"))?.to_string(); #[cfg(not(target_arch = "wasm32"))] - let text = std::fs::read_to_string(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let text = crate::io::read_to_string(&path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let text = String::new(); let mut b: Book = serde_json::from_str(&text).map_err(|e| bad("book.open", format!("not a book: {e}")))?; @@ -331,7 +331,7 @@ pub fn specs() -> Vec { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})), diff --git a/crates/engine/src/cmd/colorsettings.rs b/crates/engine/src/cmd/colorsettings.rs index c62b393e9..fc1d062a5 100644 --- a/crates/engine/src/cmd/colorsettings.rs +++ b/crates/engine/src/cmd/colorsettings.rs @@ -45,7 +45,8 @@ pub fn specs() -> Vec { let path = str_param(p, "path").ok_or_else(|| bad("color.loadProfile", "`path` required"))?; #[cfg(not(target_arch = "wasm32"))] { - let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad("color.loadProfile", e.to_string()))?; + let bytes = crate::io::read_file(path).map_err(|e| bad("color.loadProfile", e))?; + let info = cms::register_icc(&bytes, None).map_err(|e| bad("color.loadProfile", e.to_string()))?; Ok(json!(info)) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 89dd0c74e..6ecb1470d 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -340,11 +340,11 @@ fn place_image( let cands = image_candidates(cell, data_dir, doc_dir); let found = cands.iter().find_map(|p| { // Refuse huge files before reading them; a too-large image counts as missing. - let len = std::fs::metadata(p).ok()?.len(); + let len = crate::io::metadata(p).ok()?.len(); if len == 0 || len > super::MAX_IMAGE_FILE_BYTES { return None; } - std::fs::read(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b)) + crate::io::read_file(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b)) }); let Some((path, bytes)) = found else { if let Some(it) = doc.item_mut(item) { diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index 3b3e1dd3b..eda169b12 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -619,7 +619,7 @@ pub(crate) fn refresh_relative_paths(doc: &mut Document, doc_path: &Path) { /// again as `relative_path` beside the document. pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path>) { for src in &mut doc.data_merge.sources { - let abs_exists = src.path.as_deref().is_some_and(|p| Path::new(p).is_file()); + let abs_exists = src.path.as_deref().is_some_and(crate::io::is_file); if abs_exists { if let Some(path) = src.path.clone() { src.status = status_of(Path::new(&path), src.fingerprint); @@ -630,7 +630,7 @@ pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path && let Some(dir) = doc_path.parent() { let candidate = dir.join(&rel); - if candidate.is_file() { + if crate::io::is_file(&candidate) { let saved = candidate.to_string_lossy().into_owned(); src.fingerprint = fingerprint_of(Path::new(&saved)); src.status = SourceStatus::Ok; @@ -768,12 +768,14 @@ fn read_path(path: &str) -> std::result::Result, String> { } fn read_path_capped(path: &str, cap: u64) -> std::result::Result, String> { - match std::fs::metadata(path) { + match crate::io::metadata(path) { Ok(m) if m.len() > cap => return Err(format!("{path}: the data file is larger than {} MiB", cap / (1024 * 1024))), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("{path}: the data file is missing")), _ => {} } - match std::fs::read(path) { + // The roots' own message when they refuse the path. + crate::file_access::check_read(path)?; + match crate::io::read(path) { Ok(b) => Ok(b), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(format!("{path}: the data file is missing")), Err(e) => Err(format!("{path}: {e}")), diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index e65493dfc..689f45ca6 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -330,7 +330,7 @@ pub fn relative_between(from_dir: &Path, target: &Path) -> String { } pub fn fingerprint_of(path: &Path) -> Option { - let meta = std::fs::metadata(path).ok()?; + let meta = crate::io::metadata(path).ok()?; let mtime = meta .modified() .ok() diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..967349a4a 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &r.bytes)?; + crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) } None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), @@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, text.as_bytes())?; + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result { match path { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, text.as_bytes())?; + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { match p.get("path").and_then(Value::as_str) { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?; Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) } None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), @@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - crate::atomic::write_file(path, bytes) + crate::io::write_file(path, bytes).map_err(EngineError::Other) } #[cfg(target_arch = "wasm32")] fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 692c363e8..587855415 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -29,20 +29,21 @@ pub fn specs() -> Vec { super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); let bytes = to_bytes(©); #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(&path, &bytes)?; + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len()})) }), cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| { - let dir = recovery_dir(s, p)?; + let dir = recovery_dir(s, p, FolderAccess::Read)?; Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect())) }), cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { - let dir = recovery_dir(s, p)?; - Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) + let dir = recovery_dir(s, p, FolderAccess::Write)?; + Ok(json!({"saved": crate::recovery::save(s, &dir)?})) }), cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { - let dir = recovery_dir(s, p)?; + // Reopening removes the entries it read. + let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?; Ok(json!({"opened": crate::recovery::open(s, &dir)?})) }), cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| { @@ -164,7 +165,7 @@ fn file_open(s: &mut Session, p: &Value) -> Result { if path.to_ascii_lowercase().ends_with(".idml") { return super::interchange::open_idml(s, p); } - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; let mut d = from_bytes(&bytes)?; super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent()); super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); @@ -189,6 +190,11 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option (Option std::path::PathBuf { use designcraft_fonts::DOCUMENT_FONTS_FOLDER; let exact = dir.join(DOCUMENT_FONTS_FOLDER); - if exact.is_dir() { + if crate::io::is_dir(&exact) { return exact; } - std::fs::read_dir(dir) + crate::io::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()) + .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path())) .map_or(exact, |e| e.path()) } @@ -239,6 +245,8 @@ fn file_save(s: &mut Session, p: &Value) -> Result { .map(str::to_string) .or_else(|| st.path.clone()) .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; + // Before anything about the document changes: a refused save leaves it as it was. + crate::file_access::check_write(&path).map_err(EngineError::Other)?; // Prepare paths for the destination without changing the live document until persistence succeeds. let mut saved = (*st.doc).clone(); super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); @@ -246,7 +254,7 @@ fn file_save(s: &mut Session, p: &Value) -> Result { let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; let bytes = to_bytes(&saved); #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(&path, &bytes)?; + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; if paths_changed { st.doc = saved; st.revision = st.revision.saturating_add(1); @@ -268,7 +276,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { (base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None) } else if let Some(path) = str_param(p, "path") { #[cfg(not(target_arch = "wasm32"))] - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let b: Vec = vec![]; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); @@ -435,7 +443,7 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option) } let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?; #[cfg(not(target_arch = "wasm32"))] - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let b: Vec = vec![]; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); @@ -522,7 +530,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), @@ -534,7 +542,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result { fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result { const ID: &str = "file.place"; let src = if name.to_lowercase().ends_with(".idml") { - designcraft_idml::import_idml(bytes).map_err(|e| bad(ID, e.to_string()))? + super::interchange::import_idml_confined(bytes).map_err(|e| bad(ID, e.to_string()))? } else { from_bytes(bytes)? }; @@ -612,17 +620,36 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { }) } -fn recovery_dir(s: &Session, p: &Value) -> Result { +/// Which access a recovery command needs to the folder it is given. +#[derive(Clone, Copy)] +enum FolderAccess { + Read, + Write, + ReadWrite, +} + +/// The recovery folder: `dir` when the command names one, else the session's own (set by the +/// host application, so not the automation roots' business). A named folder is checked against +/// the roots for `access`. +fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result { #[cfg(target_arch = "wasm32")] { - let _ = (s, p); + let _ = (s, p, access); Err(EngineError::Other("no recovery folder on the web".into())) } #[cfg(not(target_arch = "wasm32"))] - str_param(p, "dir") - .map(std::path::PathBuf::from) - .or_else(|| s.recovery_dir.clone()) - .ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")) + { + let Some(named) = str_param(p, "dir") else { + return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")); + }; + if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) { + crate::file_access::check_read(named).map_err(EngineError::Other)?; + } + if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) { + crate::file_access::check_write(named).map_err(EngineError::Other)?; + } + Ok(std::path::PathBuf::from(named)) + } } fn can_revert(s: &Session) -> std::result::Result<(), String> { @@ -639,10 +666,10 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] { let mut d = if path.to_ascii_lowercase().ends_with(".idml") { - let bytes = std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))? + let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?; + super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(e.to_string()))? } else { - from_bytes(&std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?)? + from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)? }; // As opened again: the fonts beside it now (unchanged files are shared, not read again). let (fonts, _, _) = load_document_fonts(&mut d, &path); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 96498e863..9b9520662 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -25,13 +25,19 @@ fn export_idml(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), } } +/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`], so the +/// automation read root applies to the paths the package names. +pub(crate) fn import_idml_confined(bytes: &[u8]) -> designcraft_idml::Result { + designcraft_idml::import_idml_with(bytes, &|path| crate::io::read_file(path).ok()) +} + /// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup). pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { import_report(bytes, dir).map(|(document, _)| document) @@ -112,10 +118,10 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std:: let Some(dir) = dir else { return }; for asset in document.assets.values_mut() { let Some(link) = asset.link.as_deref() else { continue }; - if link.len() > MAX_LINK_PATH_BYTES || std::path::Path::new(link).is_file() { + if link.len() > MAX_LINK_PATH_BYTES || crate::io::is_file(link) { continue; } - let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| p.is_file()) else { continue }; + let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| crate::io::is_file(p)) else { continue }; // Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI. let path = std::path::absolute(&path).unwrap_or(path); std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned()); @@ -125,7 +131,7 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std:: #[cfg(not(target_arch = "wasm32"))] fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec)> { for path in packaged_link_candidates(link, dir) { - if let Ok(bytes) = std::fs::read(&path) { + if let Ok(bytes) = crate::io::read_file(&path) { // Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize // would introduce a verbatim prefix that is not an IDML file URI. let path = std::path::absolute(&path).unwrap_or(path); @@ -140,7 +146,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { (base64_decode(b), None, str_param(p, "name").map(|n| n.trim_end_matches(".idml").to_string())) } else if let Some(path) = str_param(p, "path") { #[cfg(not(target_arch = "wasm32"))] - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let b: Vec = Vec::new(); let pp = std::path::Path::new(path); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 384b4f827..9897885d2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -29,7 +29,7 @@ impl Library { #[cfg(not(target_arch = "wasm32"))] if let Some(p) = &self.path { let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?; - crate::atomic::write_file(p, &bytes)?; + crate::io::write_file(p, &bytes).map_err(crate::EngineError::Other)?; } Ok(()) } @@ -56,7 +56,7 @@ pub fn specs() -> Vec { (Some(j), _) => (j.to_string(), None), (None, Some(path)) => { #[cfg(not(target_arch = "wasm32"))] - let t = std::fs::read_to_string(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + let t = crate::io::read_to_string(path).map_err(crate::EngineError::Other)?; #[cfg(target_arch = "wasm32")] let t = String::new(); (t, Some(path.to_string())) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 73de3ea7e..0effead6b 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -93,11 +93,11 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str { let Some(path) = &a.link else { return "embedded" }; #[cfg(not(target_arch = "wasm32"))] { - match std::fs::metadata(path) { + match crate::io::metadata(path) { Err(_) => "missing", Ok(m) if m.len() as usize != a.data.len() => "modified", // Same size: compare the bytes (cheap for the sizes involved, and exact). - Ok(_) => match std::fs::read(path) { + Ok(_) => match crate::io::read_file(path) { Ok(b) if b == *a.data => "ok", Ok(_) => "modified", Err(_) => "missing", @@ -145,7 +145,7 @@ fn list(d: &Document) -> Vec { #[cfg(not(target_arch = "wasm32"))] fn reload(path: &str) -> Result<(Vec, (u32, u32))> { - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{path}: unsupported or corrupt image")))?; Ok((b, px)) } @@ -174,6 +174,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { fn relink_folder(s: &mut Session, p: &Value) -> Result { let dir = str_param(p, "dir").map(std::path::PathBuf::from); + if let Some(dir) = &dir { + // Refused outright: candidates outside the automation read root would only look missing. + crate::file_access::check_read(dir).map_err(EngineError::Other)?; + } let ext = str_param(p, "extension").map(|e| e.trim_start_matches('.').to_string()); if dir.is_none() && ext.is_none() { return Err(bad("links.relinkFolder", "give `dir` and/or `extension`")); @@ -194,7 +198,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { } let folder = dir.clone().or_else(|| old.parent().map(|p| p.to_path_buf())).unwrap_or_default(); let cand = folder.join(&name); - if cand.exists() { + if crate::io::exists(&cand) { todo.push((a.id, cand)); } else { not_found.push(name.to_string_lossy().to_string()); diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 25f09537c..300e7619d 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -74,6 +74,8 @@ struct OutputRoot { impl OutputRoot { fn new(path: PathBuf) -> Result { + // Before anything is created: the folder must be inside the automation write root. + crate::file_access::check_write(&path).map_err(EngineError::Other)?; ensure_real_directory(&path, false)?; let canonical = std::fs::canonicalize(&path).map_err(|e| path_error(&path, e))?; Ok(Self { path, canonical }) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4bf69ec41..c6ca1156c 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -809,7 +809,7 @@ fn value_name(color: designcraft_color::Color) -> String { fn load_swatches(s: &mut Session, p: &Value) -> Result { let bytes = match (str_param(p, "path"), str_param(p, "base64")) { (_, Some(b)) => super::file::base64_decode(b), - (Some(path), None) => std::fs::read(path).map_err(|e| bad("swatch.load", format!("{path}: {e}")))?, + (Some(path), None) => crate::io::read_file(path).map_err(|e| bad("swatch.load", e))?, _ => return Err(bad("swatch.load", "give `path` or `base64`")), }; let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?; @@ -844,11 +844,7 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result { let bytes = designcraft_color::ase::write(&chosen); match str_param(p, "path") { Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes); - #[cfg(target_arch = "wasm32")] - let written = std::fs::write(path, &bytes); - written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; + crate::io::write_file(path, &bytes).map_err(|e| bad("swatch.save", e))?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes)})), diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 6dab205fa..0b3f631e9 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -305,7 +305,7 @@ pub fn specs() -> Vec { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, text.as_bytes())?; + crate::io::write_file(path, text.as_bytes()).map_err(crate::EngineError::Other)?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"text": text, "bytes": text.len()})), @@ -385,7 +385,7 @@ fn text_or_path(p: &Value, id: &str) -> Result { (Some(t), _) => t.to_string(), (None, Some(path)) => { #[cfg(not(target_arch = "wasm32"))] - let t = std::fs::read_to_string(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + let t = crate::io::read_to_string(path).map_err(crate::EngineError::Other)?; #[cfg(target_arch = "wasm32")] let t = { let _ = path; diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 000000000..03ffd7c94 --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,515 @@ +//! The folders automation may read and write. +//! +//! `designcraft-cli mcp --automation-read-root --automation-write-root ` confines the +//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command +//! reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! +//! - The path is made absolute against the working directory, as the file system reads it. Its +//! links are followed: for a file that doesn't exist yet, the links of its nearest existing +//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so +//! is a link that leads nowhere). +//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside +//! `/work`), without regard to case on Windows. +//! - Read and write access are separate: a root given alone grants only its own kind of access. +//! +//! Roots are in force on a thread inside [`confine`], and in the whole process after +//! [`confine_process`] (`designcraft-cli mcp`), except inside [`unconfined`]. Without roots +//! nothing is checked. +//! +//! A path is checked, then opened: a process that can change the folders in between (swapping in +//! a link) can still win that race. The roots keep an agent to the files it was given; they don't +//! guard against other programs on the computer. + +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// Read access or write access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read and write; a root left out grants no access of its kind. +#[derive(Debug)] +pub struct AutomationRoots { + read: Option, + write: Option, +} + +#[derive(Debug)] +struct Root { + /// The folder made absolute, links followed: what paths are compared with. + resolved: PathBuf, + /// The same without Windows' `\\?\` prefix, for people. + shown: PathBuf, +} + +impl Root { + /// The folder at `path`, which must exist. + fn open(path: &Path, access: Access) -> Result { + let kind = match access { + Access::Read => "read", + Access::Write => "write", + }; + let resolved = std::fs::canonicalize(path) + .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) + .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; + Ok(Self { shown: plain(resolved.clone()), resolved }) + } +} + +impl AutomationRoots { + /// The roots `read` and `write`, which must be existing folders; `None` when neither is given + /// (nothing is confined then). + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { + if read.is_none() && write.is_none() { + return Ok(None); + } + let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; + let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; + Ok(Some(Arc::new(Self { read, write }))) + } + + /// The read root, absolute with its links followed. + pub fn read_root(&self) -> Option<&Path> { + self.read.as_ref().map(|r| r.shown.as_path()) + } + + /// The write root, absolute with its links followed. + pub fn write_root(&self) -> Option<&Path> { + self.write.as_ref().map(|r| r.shown.as_path()) + } + + /// May the file or folder at `path` be read (or written)? `Err` says why not. + pub fn check(&self, path: impl AsRef, access: Access) -> Result<(), String> { + let path = path.as_ref(); + let root = match access { + Access::Read => self.read.as_ref(), + Access::Write => self.write.as_ref(), + }; + let Some(Root { resolved: folder, shown }) = root else { + return match access { + Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {}", path.display())), + Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {}", path.display())), + }; + }; + if inside(&resolve(path)?, folder) { + return Ok(()); + } + let root = shown.display(); + match access { + Access::Read => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())), + Access::Write => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())), + } + } +} + +/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). +#[derive(Clone, Debug)] +enum Scope { + Confined(Arc), + Unconfined, +} + +thread_local! { + /// This thread's scope; `None`: the process's roots ([`confine_process`]). + static SCOPE: RefCell> = const { RefCell::new(None) }; +} + +/// The roots in force in every thread outside [`confine`] and [`unconfined`]. +static PROCESS: OnceLock> = OnceLock::new(); + +/// Put `roots` in force in the whole process, for good. `false` when roots were in force already +/// (they stay as they were). +pub fn confine_process(roots: Arc) -> bool { + PROCESS.set(roots).is_ok() +} + +/// Puts the scope it replaced back when dropped (also when `f` panics). +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +fn with_scope(scope: Option, f: impl FnOnce() -> T) -> T { + let _restore = Restore(SCOPE.with(|s| s.replace(scope))); + f() +} + +/// Run `f` with `roots` in force on this thread (`None`: as things stand). +pub fn confine(roots: Option<&Arc>, f: impl FnOnce() -> T) -> T { + match roots { + Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), + None => f(), + } +} + +/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the +/// app's own folders. +pub fn unconfined(f: impl FnOnce() -> T) -> T { + with_scope(Some(Scope::Unconfined), f) +} + +/// The roots in force on this thread, if any. +pub fn current() -> Option> { + match SCOPE.with(|s| s.borrow().clone()) { + Some(Scope::Confined(r)) => Some(r), + Some(Scope::Unconfined) => None, + None => PROCESS.get().cloned(), + } +} + +/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. +fn plain(path: PathBuf) -> PathBuf { + if !cfg!(windows) { + return path; + } + let text = path.to_string_lossy(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => path, + } +} + +/// May `path` be read here? Always, without roots in force. +pub fn check_read(path: impl AsRef) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Read)) +} + +/// May `path` be written here? Always, without roots in force. +pub fn check_write(path: impl AsRef) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Write)) +} + +/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest +/// existing folder's links followed and the rest appended. +fn resolve(path: &Path) -> Result { + if path.as_os_str().is_empty() { + return Err("automation path rejected: path is empty".into()); + } + // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. + if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { + return Err(format!("automation path rejected: reserved device names are not allowed: {}", path.display())); + } + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {}: {e}", path.display()))?; + // The names below the nearest existing folder, last first. + let mut missing: Vec = vec![]; + let mut at = absolute.as_path(); + loop { + match std::fs::canonicalize(at) { + Ok(mut found) => { + found.extend(missing.iter().rev()); + return Ok(found); + } + Err(e) => match std::fs::symlink_metadata(at) { + Ok(m) if m.file_type().is_symlink() => { + return Err(format!("automation path rejected: a link that leads nowhere: {}", path.display())); + } + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())), + }, + } + let parent = at.parent(); + match (at.components().next_back(), parent) { + (Some(Component::Normal(name)), Some(parent)) => { + missing.push(name.to_os_string()); + at = parent; + } + (Some(Component::ParentDir), _) => { + return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {}", path.display())); + } + // Down to the root (or drive) and it doesn't exist either. + _ => { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())); + } + } + } +} + +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on +/// Windows, whose file systems ignore it)? +fn inside(path: &Path, root: &Path) -> bool { + let mut path = path.components(); + root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) +} + +fn same_component(a: Component, b: Component) -> bool { + if a == b { + return true; + } + if !cfg!(windows) { + return false; + } + match (a.as_os_str().to_str(), b.as_os_str().to_str()) { + (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + _ => false, + } +} + +/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +fn is_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. + fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("dc-file-access-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in/sub", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.designcraft"), "x").unwrap(); + std::fs::write(base.join("outside/secret.designcraft"), "x").unwrap(); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) + } + + fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() + } + + #[test] + fn paths_inside_the_roots_pass_and_others_dont() { + let (base, roots) = folders("inside"); + assert_eq!(roots.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(s(base.join("in")), Access::Read), Ok(()), "the root itself"); + assert_eq!(roots.check(s(base.join("in/sub/new.designcraft")), Access::Read), Ok(()), "missing files are checked by their folder"); + assert_eq!(roots.check(s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder"); + let e = roots.check(s(base.join("outside/secret.designcraft")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + // Read and write access are separate. + let e = roots.check(s(base.join("in/a.designcraft")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(roots.check(s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_sibling_with_the_same_prefix_is_outside() { + let (base, roots) = folders("prefix"); + std::fs::write(base.join("in2/b.designcraft"), "x").unwrap(); + assert!(roots.check(s(base.join("in2/b.designcraft")), Access::Read).is_err(), "in2 is not inside in"); + assert!(roots.check(format!("{}2/c.designcraft", s(base.join("in"))), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_is_resolved_or_refused() { + let (base, roots) = folders("dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.designcraft", s(base.join("in"))); + assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); + let back = format!("{}{sep}..{sep}a.designcraft", s(base.join("in/sub"))); + assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside"); + let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.designcraft", s(base.join("out"))); + let e = roots.check(&missing, Access::Write).unwrap_err(); + // Windows resolves `..` by name first (so does opening the file there): then it's outside. + assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + let (base, roots) = folders("relative"); + // The tests' working directory is the crate's folder: not inside the roots. + assert!(roots.check("a.designcraft", Access::Read).unwrap_err().contains("outside the read root")); + assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_missing_root_is_an_error_and_none_is_no_confinement() { + let (base, _) = folders("missing"); + let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root"), "{e}"); + let e = AutomationRoots::new(None, Some(&base.join("in/a.designcraft"))).unwrap_err(); + assert!( + e.starts_with("cannot open the automation write root") + && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), + "{e}" + ); + assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(None, None).unwrap().is_none()); + // One root alone grants only its own access. + let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let e = read_only.check(s(base.join("in/x.designcraft")), Access::Write).unwrap_err(); + assert!(e.contains("write authority is absent"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn links_are_followed_before_comparing() { + let (base, roots) = folders("links"); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| { + let made = + if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; + made.is_ok() + }; + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make links: nothing to test without them. + if !made(&base.join("outside/secret.designcraft"), &base.join("in/leak.designcraft")) { + eprintln!("skipped: symbolic links can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(roots.check(s(base.join("in/leak.designcraft")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); + assert!(made(&base.join("outside"), &base.join("out/away"))); + let e = roots.check(s(base.join("out/away/new.png")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "a folder link out: {e}"); + assert!(made(&base.join("outside/gone.designcraft"), &base.join("out/dangling.png"))); + let e = roots.check(s(base.join("out/dangling.png")), Access::Write).unwrap_err(); + assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); + // A link to a folder inside the root stays inside. + assert!(made(&base.join("in/sub"), &base.join("in/alias"))); + assert_eq!(roots.check(s(base.join("in/alias/x.designcraft")), Access::Read), Ok(())); + // A root given through a link is the folder it leads to. + assert!(made(&base.join("in"), &base.join("in-link"))); + let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + assert_eq!(linked.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_refuses_devices() { + let (base, roots) = folders("case"); + let upper = s(base.join("IN/a.designcraft")); + assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(s(base.join("In/Sub/New.designcraft")), Access::Read), Ok(())); + let e = roots.check(s(base.join("out/NUL")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); + assert!(roots.check(s(base.join("out/com1.txt")), Access::Write).is_err()); + assert_eq!(roots.check(s(base.join("out/console.txt")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn scopes_nest_and_stay_on_their_thread() { + let (base, roots) = folders("scopes"); + let outside = s(base.join("outside/secret.designcraft")); + assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); + confine(Some(&roots), || { + assert!(check_read(&outside).is_err()); + assert_eq!(unconfined(|| check_read(&outside)), Ok(())); + assert!(check_read(&outside).is_err(), "restored after unconfined"); + let o = outside.clone(); + let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); + assert!(there, "another thread is not confined by this thread's scope"); + }); + assert_eq!(check_read(&outside), Ok(()), "restored after confine"); + let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); + assert!(r.is_err()); + assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); + assert_eq!(confine(None, || check_read(&outside)), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn device_names() { + for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.designcraft", "NUL "] { + assert!(is_device_name(d), "{d}"); + } + for f in ["console", "com10", "COM0", "lpt", "a.designcraft", "nul-x"] { + assert!(!is_device_name(f), "{f}"); + } + } + + /// Every file a command reads or writes by path goes through `io` (`read_file`, + /// `write_file`, `metadata`, `read_dir`...), which check the roots. Engine code that reaches + /// for `std::fs` itself would bypass them, so none may: every file under `src/` (commands, + /// `lib.rs`, `guard.rs`...) except + /// + /// - `io.rs` and this module, the checked gateway, + /// - `recovery.rs`, which keeps the unsaved documents in a folder the host application + /// chooses (a command's own `dir` is checked in `cmd/file.rs` before it gets there), + /// - `cmd/package.rs`, whose `OutputRoot` checks the folder against the write root before it + /// creates anything and then only writes inside it. Its reads are of the font files that + /// draw the document's text (installed fonts and the `Document Fonts` folder), never of a + /// path a command names, + /// - the test files. + #[test] + fn commands_do_not_touch_the_file_system_themselves() { + fn walk(dir: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, files); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + /// The file's lines but its test modules (which build their own fixtures): a module is + /// skipped from its `#[cfg(test)]` to the closing brace at the same indent. + fn code_outside_tests(text: &str) -> Vec<(usize, &str)> { + let lines: Vec<&str> = text.lines().collect(); + let mut code = vec![]; + let mut i = 0; + while i < lines.len() { + let attr = lines[i].trim(); + let module = lines.get(i + 1).map(|l| l.trim_start().trim_start_matches("pub ")); + if attr.starts_with("#[cfg(") && attr.contains("test") && module.is_some_and(|m| m.starts_with("mod ")) { + let header = lines[i + 1]; + i += 2; + if header.trim_end().ends_with('{') { + let indent = &header[..header.len() - header.trim_start().len()]; + let close = format!("{indent}}}"); + while i < lines.len() && lines[i].trim_end() != close { + i += 1; + } + i += 1; + } + continue; + } + code.push((i + 1, lines[i])); + i += 1; + } + code + } + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = vec![]; + walk(&src, &mut files); + assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); + let mut offenders = vec![]; + for path in files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let rel = path.strip_prefix(&src).unwrap_or(&path).to_string_lossy().replace('\\', "/"); + if matches!(rel.as_str(), "io.rs" | "file_access.rs" | "recovery.rs" | "cmd/package.rs") || name.starts_with("tests") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + for (n, line) in code_outside_tests(&text) { + let line = line.trim_start(); + let touches = + ["std::fs", "File::open", "File::create", "OpenOptions", ".is_file()", ".is_dir()", ".exists()"].iter().any(|w| line.contains(w)); + if !line.starts_with("//") && touches { + offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); + } + } + } + assert!(offenders.is_empty(), "use crate::io (read_file, write_file, metadata, read_dir...): {offenders:#?}"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs new file mode 100644 index 000000000..4fa214750 --- /dev/null +++ b/crates/engine/src/io.rs @@ -0,0 +1,71 @@ +//! Reading and writing the files a command names by path. +//! +//! Every such read and write goes through this module, so the automation roots +//! ([`crate::file_access`]) are checked in one place. A command module that called `std::fs` +//! itself would bypass them: a test in `file_access` keeps that from happening. +//! +//! A path the roots refuse reads as a file that isn't there where the caller only asks whether +//! it is ([`is_file`], [`is_dir`], [`exists`], [`metadata`]), and as the roots' own message where +//! it asks for the bytes. + +use std::fs::Metadata; +use std::path::Path; + +/// The bytes of the file at `path`, inside the automation read root when there is one. +pub fn read_file(path: impl AsRef) -> Result, String> { + let path = path.as_ref(); + crate::file_access::check_read(path)?; + std::fs::read(path).map_err(|e| format!("{}: {e}", path.display())) +} + +/// [`read_file`] with the file system's own error, for callers that tell "missing" from other +/// failures. A refused path is a `PermissionDenied` error carrying the roots' message. +pub fn read(path: impl AsRef) -> std::io::Result> { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::read(path) +} + +/// The text of the file at `path`, inside the automation read root when there is one. +pub fn read_to_string(path: impl AsRef) -> Result { + let path = path.as_ref(); + crate::file_access::check_read(path)?; + std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display())) +} + +/// Write `bytes` to the file at `path`, inside the automation write root when there is one. +pub fn write_file(path: impl AsRef, bytes: &[u8]) -> Result<(), String> { + let path = path.as_ref(); + crate::file_access::check_write(path)?; + std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) +} + +/// What the file system says about `path` (links followed), inside the automation read root when +/// there is one. A refused path is a `PermissionDenied` error carrying the roots' message. +pub fn metadata(path: impl AsRef) -> std::io::Result { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::metadata(path) +} + +/// Is there a file at `path` that automation may read? +pub fn is_file(path: impl AsRef) -> bool { + metadata(path).is_ok_and(|m| m.is_file()) +} + +/// Is there a folder at `path` that automation may read? +pub fn is_dir(path: impl AsRef) -> bool { + metadata(path).is_ok_and(|m| m.is_dir()) +} + +/// Is there anything at `path` that automation may read? +pub fn exists(path: impl AsRef) -> bool { + metadata(path).is_ok() +} + +/// The entries of the folder at `path`, inside the automation read root when there is one. +pub fn read_dir(path: impl AsRef) -> std::io::Result { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::read_dir(path) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2165eebc4..1f06cbade 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,7 +11,9 @@ pub mod atomic; pub mod cmd; pub mod dtd; +pub mod file_access; pub mod guard; +pub mod io; pub mod links; pub mod math; pub mod recovery; @@ -558,4 +560,6 @@ mod tests; #[cfg(test)] mod tests_idml; #[cfg(test)] +mod tests_roots; +#[cfg(test)] mod tests_table; diff --git a/crates/engine/src/tests_roots.rs b/crates/engine/src/tests_roots.rs new file mode 100644 index 000000000..443403b0a --- /dev/null +++ b/crates/engine/src/tests_roots.rs @@ -0,0 +1,212 @@ +//! The automation roots at the engine: the files a document names (links, data sources, book +//! chapters, packaged fonts) are read and written through the same checks as the paths a command +//! is given. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{Value, json}; + +use crate::file_access::{AutomationRoots, confine}; +use crate::{Session, cmd}; + +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +/// A fresh folder with `in` (the read root), `out` (the write root) and `outside`, and the roots. +fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("dc-engine-roots-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) +} + +fn p(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +/// A document with `pic.png` placed from `dir`, so the asset is embedded and linked to the file. +fn linked_picture(dir: &Path) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": p(&dir.join("pic.png")), "x": 36, "y": 36, "width": 72})).unwrap(); + s +} + +fn link_status(s: &mut Session) -> Value { + s.execute("links.list", &json!({})).unwrap()[0]["status"].clone() +} + +#[test] +fn a_link_outside_the_read_root_reads_as_missing() { + let (base, roots) = folders("links"); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + std::fs::write(base.join("in/pic.png"), PNG).unwrap(); + let mut outside = linked_picture(&base.join("outside")); + let mut inside = linked_picture(&base.join("in")); + assert_eq!(link_status(&mut outside), "ok"); + confine(Some(&roots), || { + assert_eq!(link_status(&mut outside), "missing", "the file is not the agent's to look at, not even to compare"); + assert_eq!(link_status(&mut inside), "ok"); + assert!(outside.execute("links.relinkFolder", &json!({"dir": p(&base.join("outside"))})).is_err()); + // A relink to a link that leads out is refused too, and the asset keeps what it had. + let asset = outside.execute("links.list", &json!({})).unwrap()[0]["asset"].clone(); + let e = outside.execute("links.relink", &json!({"asset": asset, "path": p(&base.join("outside/pic.png"))})).unwrap_err().to_string(); + assert!(e.contains("outside the read root"), "{e}"); + }); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn idml_links_are_read_through_the_roots() { + let (base, roots) = folders("idml"); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + let mut s = linked_picture(&base.join("outside")); + // Linked, not embedded: opening the package has to read the file its link names. + let idml = base64_idml(&mut s); + let linked = |doc: &designcraft_doc::Document| doc.assets.values().filter(|a| !a.data.is_empty()).count(); + let unconfined = cmd::interchange::import_idml_confined(&idml).unwrap(); + assert_eq!(linked(&unconfined), 1, "without roots the link is followed"); + let confined = confine(Some(&roots), || cmd::interchange::import_idml_confined(&idml)).unwrap(); + assert_eq!(linked(&confined), 0, "with roots a link outside the read root is not read"); + // The same through the commands that open or place a package. + let b64 = cmd::base64_encode(&idml); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + confine(Some(&roots), || { + t.execute("file.openBytes", &json!({"name": "x.idml", "base64": b64})).unwrap(); + let doc = &t.doc().unwrap().doc; + assert_eq!(linked(doc), 0); + t.execute("file.place", &json!({"base64": b64, "name": "x.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap(); + assert_eq!(linked(&t.doc().unwrap().doc), 0); + }); + let _ = std::fs::remove_dir_all(base); +} + +fn base64_idml(s: &mut Session) -> Vec { + let r = s.execute("file.exportIdml", &json!({"embedImages": false})).unwrap(); + cmd::base64_decode(r["base64"].as_str().unwrap()) +} + +#[test] +fn a_merge_does_not_read_image_files_outside_the_read_root() { + let (base, roots) = folders("merge"); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + std::fs::write(base.join("in/data.csv"), format!("@Photo\n{}\n", base.join("outside/pic.png").display())).unwrap(); + let merge = |s: &mut Session| { + s.execute("file.new", &json!({})).unwrap(); + s.execute("data.source.select", &json!({"path": p(&base.join("in/data.csv"))})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})).unwrap(); + s.execute("data.placeholder.add", &json!({"field": "Photo", "item": frame["id"]})).unwrap(); + s.execute("data.merge", &json!({})).unwrap()["missingImages"].as_array().unwrap().len() + }; + assert_eq!(merge(&mut Session::new()), 0, "without roots the picture is found"); + assert_eq!(confine(Some(&roots), || merge(&mut Session::new())), 1, "with roots it reads as missing"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn a_data_source_path_stored_in_a_document_is_read_through_the_roots() { + let (base, roots) = folders("source"); + std::fs::write(base.join("outside/data.csv"), "name\nTOP SECRET\n").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("data.source.select", &json!({"path": p(&base.join("outside/data.csv"))})).unwrap(); + confine(Some(&roots), || { + let e = s.execute("data.source.update", &json!({})).unwrap_err().to_string(); + assert!(e.contains("outside the read root") && !e.contains("TOP SECRET"), "{e}"); + }); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn a_book_writes_its_chapters_only_inside_the_write_root() { + let (base, roots) = folders("book"); + let chapter = base.join("in/a.designcraft"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.save", &json!({"path": p(&chapter)})).unwrap(); + let second = base.join("in/b.designcraft"); + s.execute("file.saveAs", &json!({"path": p(&second)})).unwrap(); + let before = (std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap()); + confine(Some(&roots), || { + s.execute("book.new", &json!({"path": p(&base.join("out/b.dcbook"))})).unwrap(); + s.execute("book.add", &json!({"path": p(&chapter)})).unwrap(); + s.execute("book.add", &json!({"path": p(&second)})).unwrap(); + for command in ["book.paginate", "book.syncStyles", "book.exportPdf"] { + let r = s.execute(command, &json!({"path": p(&base.join("outside/book.pdf"))})); + let e = r.unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{command}: {e}"); + } + let e = s.execute("book.new", &json!({"path": p(&base.join("outside/b.dcbook"))})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + }); + assert_eq!((std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap()), before, "no chapter was rewritten"); + assert!(!base.join("outside/b.dcbook").exists()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn package_and_copy_links_create_nothing_outside_the_write_root() { + let (base, roots) = folders("package"); + std::fs::write(base.join("in/pic.png"), PNG).unwrap(); + let mut s = linked_picture(&base.join("in")); + confine(Some(&roots), || { + for command in ["file.package", "links.copyTo"] { + let e = s.execute(command, &json!({"dir": p(&base.join("outside/pkg"))})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{command}: {e}"); + // The read root is not writable either. + let e = s.execute(command, &json!({"dir": p(&base.join("in/pkg"))})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{command}: {e}"); + } + s.execute("file.package", &json!({"dir": p(&base.join("out/pkg"))})).unwrap(); + s.execute("links.copyTo", &json!({"dir": p(&base.join("out/copies"))})).unwrap(); + }); + assert!(!base.join("outside/pkg").exists() && !base.join("in/pkg").exists()); + assert!(base.join("out/pkg/Instructions.txt").exists() && base.join("out/copies/pic.png").exists()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn recovery_folders_named_by_a_command_are_checked() { + let (base, roots) = folders("recovery"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + confine(Some(&roots), || { + let out = p(&base.join("outside")); + let inside = p(&base.join("in")); + let to_write = p(&base.join("out")); + let e = s.execute("file.recovery.save", &json!({"dir": out})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + let e = s.execute("file.recovery.save", &json!({"dir": inside})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(s.execute("file.recovery.list", &json!({"dir": out})).unwrap_err().to_string().contains("outside the read root")); + // Reopening removes what it read, so it needs both. + assert!(s.execute("file.recovery.open", &json!({"dir": inside})).unwrap_err().to_string().contains("outside the write root")); + assert!(s.execute("file.recovery.open", &json!({"dir": to_write})).unwrap_err().to_string().contains("outside the read root")); + assert_eq!(s.execute("file.recovery.save", &json!({"dir": to_write})).unwrap()["saved"], 1); + }); + assert!(std::fs::read_dir(base.join("outside")).unwrap().next().is_none()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn fonts_beside_a_document_outside_the_read_root_are_not_loaded() { + let (base, roots) = folders("fonts"); + std::fs::create_dir_all(base.join("outside/Document Fonts")).unwrap(); + std::fs::write(base.join("outside/Document Fonts/x.ttf"), b"not a font").unwrap(); + let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); + let (_, faces, warnings) = confine(Some(&roots), || cmd::load_document_fonts(&mut d, &p(&base.join("outside/doc.designcraft")))); + assert_eq!(faces, 0); + assert!(warnings.iter().any(|w| w.contains("outside the read root")), "{warnings:?}"); + let (_, _, warnings) = cmd::load_document_fonts(&mut d, &p(&base.join("outside/doc.designcraft"))); + assert!(warnings.iter().any(|w| w.contains("x.ttf")), "without roots the folder is read and the bad font reported: {warnings:?}"); + let _ = std::fs::remove_dir_all(base); +} From 54330aa63278fb2cca0ea1ce6e2ab889f2bf6f62 Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 10:57:59 -0400 Subject: [PATCH 008/128] MCP: confine the headless session to the automation roots --- crates/mcp/src/backend.rs | 6 + crates/mcp/src/headless.rs | 44 +++- crates/mcp/src/tools.rs | 2 +- crates/mcp/tests/automation_roots.rs | 349 +++++++++++++++++++++++++++ 4 files changed, 389 insertions(+), 12 deletions(-) create mode 100644 crates/mcp/tests/automation_roots.rs diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index c4930cc99..dc705e98f 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -13,6 +13,12 @@ pub trait Backend { fn call(&mut self, method: &str, params: Value) -> Result; /// True when a real UI is attached (`ui.screenshot`, `ui.click`, dialogs… work). fn has_ui(&self) -> bool; + /// Write `bytes` to the file at `path` for a tool that was asked to keep a file (the PNG of + /// `render_page`). A backend that confines file access (see [`crate::Headless`]) applies it + /// here too. + fn write_file(&mut self, path: &str, bytes: &[u8]) -> Result<(), String> { + designcraft_engine::io::write_file(path, bytes) + } /// Short human description ("headless", "connected to 127.0.0.1:7979"). fn describe(&self) -> String; } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index eea81ddd3..c6277fbf2 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -1,5 +1,8 @@ //! An in-process engine session that answers the engine-level control-channel methods itself. +use std::sync::Arc; + +use designcraft_engine::file_access::{self, AutomationRoots}; use designcraft_engine::{Session, ViewInfo}; use designcraft_geom::Point; use designcraft_render::{RenderOptions, Renderer}; @@ -12,6 +15,7 @@ use crate::backend::Backend; pub struct Headless { pub session: Session, pub renderer: Renderer, + roots: Option>, } impl Default for Headless { @@ -82,7 +86,14 @@ fn mods_from(p: &Value) -> Mods { impl Headless { /// A session with no document (the first `file.new` / `file.open` creates one). pub fn new() -> Self { - Self { session: Session::new(), renderer: Renderer::new() } + Self { session: Session::new(), renderer: Renderer::new(), roots: None } + } + + /// Confine every file the session's commands read and write to `roots` + /// (`designcraft-cli mcp --automation-read-root … --automation-write-root …`). + pub fn with_automation_roots(mut self, roots: Option>) -> Self { + self.roots = roots; + self } /// A session with a fresh default (Letter) document, ready to lay out. @@ -201,7 +212,7 @@ impl Headless { let png = img.to_png(); match s(p, "path") { Some(path) => { - designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::io::write_file(path, &png)?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})), @@ -221,13 +232,32 @@ impl Headless { .ok_or_else(|| format!("no page {page}"))?; let lower = path.to_ascii_lowercase(); let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; - designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::io::write_file(path, &bytes)?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } } impl Backend for Headless { fn call(&mut self, method: &str, params: Value) -> Result { + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.answer(method, params)) + } + + fn write_file(&mut self, path: &str, bytes: &[u8]) -> Result<(), String> { + file_access::confine(self.roots.as_ref(), || designcraft_engine::io::write_file(path, bytes)) + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "headless (in-process engine, no window)".into() + } +} + +impl Headless { + fn answer(&mut self, method: &str, params: Value) -> Result { let p = ¶ms; match method { "engine.execute" | "ui.menu.invoke" | "command" => { @@ -254,14 +284,6 @@ impl Backend for Headless { other => Err(format!("unknown method `{other}`")), } } - - fn has_ui(&self) -> bool { - false - } - - fn describe(&self) -> String { - "headless (in-process engine, no window)".into() - } } /// A supplied page must be a representable unsigned index; only omission selects page zero. diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c51bac8ed..553ce400a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -479,7 +479,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result { let path = a.get("path").and_then(Value::as_str); if let Some(path) = path { let png = designcraft_engine::cmd::base64_decode(&b64); - designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; + b.write_file(path, &png)?; } Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path}))) } diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs new file mode 100644 index 000000000..87ce411e8 --- /dev/null +++ b/crates/mcp/tests/automation_roots.rs @@ -0,0 +1,349 @@ +//! `designcraft-cli mcp --automation-read-root --automation-write-root `: through the MCP +//! protocol, every file a command reads comes from inside the read root and every file it writes +//! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the +//! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing +//! is written outside. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; + +use designcraft_engine::Session; +use designcraft_engine::file_access::AutomationRoots; +use designcraft_mcp::{Headless, Server}; +use serde_json::{Value, json}; + +/// What the secret files hold, so a test can look for it in everything the agent is told. +const SECRET: &str = "TOP SECRET"; + +/// A 1x1 PNG, so `place_image` has something real to read. +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +/// A `.designcraft` file of `pages` pages with `text` in a frame on the first, made outside any roots. +fn designcraft_file(path: &Path, pages: u32, text: &str) { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": pages})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 100], "content": "text", "text": text})).unwrap(); + s.execute("file.save", &json!({"path": path.to_str().unwrap()})).unwrap(); +} + +/// A fresh folder holding `in` (the read root), `out` (the write root), `outside` and `in2` (a +/// sibling whose name starts with `in`), and a server confined to `in` and `out` (either may be +/// left out). `in` and `outside` each hold the same kinds of files, those outside carrying +/// [`SECRET`]. The server starts with a new one-page document. +fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) { + let base = std::env::temp_dir().join(format!("dc-mcp-roots-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + for (dir, text) in [("in", "Inside text"), ("outside", SECRET), ("in2", "Sibling")] { + let dir = base.join(dir); + designcraft_file(&dir.join("doc.designcraft"), if dir.ends_with("in") { 1 } else { 3 }, text); + std::fs::write(dir.join("pic.png"), PNG).unwrap(); + std::fs::write(dir.join("data.csv"), format!("name,city\n{text},Oslo\n")).unwrap(); + std::fs::write(dir.join("swatches.ase"), b"not a swatch file").unwrap(); + std::fs::write(dir.join("profile.icc"), b"not a profile").unwrap(); + std::fs::write(dir.join("lib.json"), format!("{{\"items\": [], \"note\": \"{text}\"}}")).unwrap(); + std::fs::write(dir.join("shape.dtd"), format!("", text.replace(' ', "_"))).unwrap(); + std::fs::write(dir.join("tags.xml"), format!("

{text}

")).unwrap(); + } + let (in_dir, out_dir) = (base.join("in"), base.join("out")); + let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap(); + let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots))); + ok(&mut server, "new_document", json!({})); + (server, base) +} + +fn s(p: &Path) -> String { + p.to_string_lossy().into_owned() +} + +/// `tools/call` -> (isError, every text block). +fn call(server: &mut Server, name: &str, args: Value) -> (bool, String) { + let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string(); + let reply: Value = serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap(); + let result = &reply["result"]; + let text = result["content"].as_array().unwrap().iter().filter_map(|c| c["text"].as_str()).collect::>().join("\n"); + (result["isError"] == true, text) +} + +fn ok(server: &mut Server, name: &str, args: Value) -> String { + let (error, text) = call(server, name, args.clone()); + assert!(!error, "{name} {args}: {text}"); + text +} + +/// A command through the `execute` tool. +fn run(server: &mut Server, command: &str, params: Value) -> (bool, String) { + call(server, "execute", json!({"command": command, "params": params})) +} + +fn run_ok(server: &mut Server, command: &str, params: Value) -> Value { + let (error, text) = run(server, command, params.clone()); + assert!(!error, "{command} {params}: {text}"); + serde_json::from_str(&text).unwrap_or(Value::Null) +} + +/// `command` is refused with `why` in its message, and the message says nothing of the file. +fn refused(server: &mut Server, command: &str, params: Value, why: &str) { + let (error, text) = run(server, command, params.clone()); + assert!(error && text.contains(why), "{command} {params} should be refused ({why}): {text}"); + assert!(!text.contains(SECRET), "{command}: {text}"); +} + +/// The open document's page count, which tells the three-page files outside from the one-page one. +fn pages(server: &mut Server) -> usize { + let doc: Value = serde_json::from_str(&ok(server, "inspect_document", json!({}))).unwrap(); + doc["pageCount"].as_u64().unwrap() as usize +} + +fn outside_names(base: &Path) -> Vec { + let mut names: Vec = + std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + names +} + +#[test] +fn documents_open_only_from_inside_the_read_root() { + let (mut server, base) = confined("open", true, true); + let sep = std::path::MAIN_SEPARATOR; + ok(&mut server, "open_document", json!({"path": s(&base.join("in/doc.designcraft"))})); + assert_eq!(pages(&mut server), 1, "the file inside the root is the open document"); + + let escape = format!("{}{sep}..{sep}outside{sep}doc.designcraft", s(&base.join("in"))); + let paths = [s(&base.join("outside/doc.designcraft")), escape, s(&base.join("in2/doc.designcraft")), s(&base.join("out/new.designcraft"))]; + for path in paths { + let (error, text) = call(&mut server, "open_document", json!({"path": path})); + assert!(error && text.contains("outside the read root"), "{path}: {text}"); + assert!(!text.contains(SECRET), "{text}"); + refused(&mut server, "file.open", json!({"path": path}), "outside the read root"); + refused(&mut server, "file.openIdml", json!({"path": path}), "outside the read root"); + } + // Nothing from the refused files reached the session: the open document is still the one it was. + assert_eq!(pages(&mut server), 1); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn every_command_that_reads_a_path_is_refused_outside_the_read_root() { + let (mut server, base) = confined("reads", true, true); + let outside = |name: &str| s(&base.join("outside").join(name)); + let inside = |name: &str| s(&base.join("in").join(name)); + + // A placed image to relink and a data source to update, both from inside the root. + ok(&mut server, "place_image", json!({"path": inside("pic.png"), "x": 36, "y": 36, "width": 72})); + let links = run_ok(&mut server, "links.list", json!({})); + let asset = links[0]["asset"].clone(); + let fields = run_ok(&mut server, "data.source.select", json!({"path": inside("data.csv")})); + assert!(fields["fields"].to_string().contains("city"), "{fields}"); + let before = ok(&mut server, "inspect_document", json!({})); + + let reads: Vec<(&str, Value)> = vec![ + ("file.place", json!({"path": outside("pic.png")})), + ("place.load", json!({"path": outside("pic.png")})), + ("place.styles", json!({"path": outside("tags.xml")})), + ("links.relink", json!({"asset": asset, "path": outside("pic.png")})), + ("links.relinkFolder", json!({"dir": s(&base.join("outside"))})), + ("media.options", json!({"poster": {"path": outside("pic.png")}})), + ("color.loadProfile", json!({"path": outside("profile.icc")})), + ("swatch.load", json!({"path": outside("swatches.ase")})), + ("library.open", json!({"path": outside("lib.json")})), + ("book.open", json!({"path": outside("lib.json")})), + ("data.source.select", json!({"path": outside("data.csv")})), + ("data.merge", json!({"path": outside("data.csv")})), + ("xml.loadDtd", json!({"path": outside("shape.dtd")})), + ("file.importXml", json!({"path": outside("tags.xml")})), + ("file.recovery.list", json!({"dir": s(&base.join("outside"))})), + ("file.recovery.open", json!({"dir": s(&base.join("outside"))})), + ]; + for (command, params) in reads { + refused(&mut server, command, params, "outside the read root"); + } + let (error, text) = call(&mut server, "place_image", json!({"path": outside("pic.png")})); + assert!(error && text.contains("outside the read root") && !text.contains(SECRET), "{text}"); + // The control-channel method behind `open_document`. + let (error, text) = call(&mut server, "execute", json!({"command": "app.open", "params": {"path": outside("doc.designcraft")}})); + assert!(error && !text.contains(SECRET), "app.open: {text}"); + assert_eq!(ok(&mut server, "inspect_document", json!({})), before, "the refused reads changed nothing"); + assert_eq!(run_ok(&mut server, "data.source.select", json!({"path": inside("data.csv")}))["records"], 1); + + // Inside the root the same commands get as far as reading the file, so the table above is not + // refusing for some other reason: a file that isn't a profile or swatch file is reported as such. + for (command, params) in [("color.loadProfile", json!({"path": inside("profile.icc")})), ("swatch.load", json!({"path": inside("swatches.ase")}))] + { + let (error, text) = run(&mut server, command, params); + assert!(error && !text.contains("automation"), "{command}: {text}"); + } + run_ok(&mut server, "xml.loadDtd", json!({"path": inside("shape.dtd")})); + run_ok(&mut server, "file.importXml", json!({"path": inside("tags.xml")})); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn files_named_inside_a_document_are_read_through_the_roots_too() { + let (mut server, base) = confined("stored", true, true); + // A document in the read root whose picture is linked to a file outside it, and whose data + // source is a file outside it, made by someone without roots. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + std::fs::write(base.join("outside/linked.png"), PNG).unwrap(); + s.execute("file.place", &json!({"path": self::s(&base.join("outside/linked.png")), "x": 36, "y": 36, "width": 72})).unwrap(); + s.execute("data.source.select", &json!({"path": self::s(&base.join("outside/data.csv"))})).unwrap(); + s.execute("file.save", &json!({"path": self::s(&base.join("in/linked.designcraft"))})).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok"); + + ok(&mut server, "open_document", json!({"path": self::s(&base.join("in/linked.designcraft"))})); + let links = run_ok(&mut server, "links.list", json!({})); + assert_eq!(links[0]["status"], "missing", "a link outside the read root reads as a file that isn't there: {links}"); + refused(&mut server, "data.source.update", json!({}), "outside the read root"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn every_writing_command_is_refused_outside_the_write_root() { + let (mut server, base) = confined("write", true, true); + let outside = |name: &str| s(&base.join("outside").join(name)); + let inside = |name: &str| s(&base.join("out").join(name)); + let fixtures = outside_names(&base); + // Something to export: a picture to package and a text frame, selected so `file.exportText` + // and `snippet.export` have their input. + ok(&mut server, "place_image", json!({"path": s(&base.join("in/pic.png")), "x": 36, "y": 200, "width": 72})); + run_ok(&mut server, "frame.create", json!({"rect": [36, 300, 300, 100], "content": "text", "text": "Selected"})); + run_ok(&mut server, "select.firstAbove", json!({})); + + // Every command that writes a file, with the path parameter it takes. + let writes: Vec<(&str, &str)> = vec![ + ("file.save", "path"), + ("file.saveAs", "path"), + ("file.saveACopy", "path"), + ("file.exportPdf", "path"), + ("file.exportEpub", "path"), + ("file.exportFixedEpub", "path"), + ("file.exportHtml", "path"), + ("file.exportText", "path"), + ("file.exportXml", "path"), + ("file.exportIdml", "path"), + ("file.printBooklet", "path"), + ("snippet.export", "path"), + ("swatch.save", "path"), + ("library.new", "path"), + ("book.exportPdf", "path"), + ("book.new", "path"), + ("file.package", "dir"), + ("links.copyTo", "dir"), + ("file.recovery.save", "dir"), + ]; + // A book with a chapter to export, so `book.exportPdf` gets as far as its path. + run_ok(&mut server, "book.new", json!({"path": inside("first.dcbook")})); + run_ok(&mut server, "book.add", json!({"path": s(&base.join("in/doc.designcraft"))})); + for (command, key) in &writes { + let target = if *key == "dir" { outside("folder") } else { outside(&format!("out-{command}")) }; + refused(&mut server, command, json!({*key: target}), "outside the write root"); + } + for tool in ["save_document", "export_png", "render_page"] { + let (error, text) = call(&mut server, tool, json!({"path": outside("tool.out")})); + assert!(error && text.contains("outside the write root"), "{tool}: {text}"); + } + // The control-channel methods behind `save_document` and `export_png`. + for method in ["app.save", "app.export"] { + let (error, text) = call(&mut server, "execute", json!({"command": method, "params": {"path": outside("app.out")}})); + assert!(error, "{method}: {text}"); + } + // A sibling folder with the same prefix, `..` out of the root, and the read root. + let sep = std::path::MAIN_SEPARATOR; + for path in [ + s(&base.join("out2/x.designcraft")), + format!("{}{sep}..{sep}outside{sep}x.designcraft", s(&base.join("out"))), + s(&base.join("in/x.designcraft")), + ] { + let (error, text) = call(&mut server, "save_document", json!({"path": path})); + assert!(error && text.contains("outside the write root"), "{path}: {text}"); + } + assert_eq!(outside_names(&base), fixtures, "nothing was written outside beside the files made at the start"); + assert!(!base.join("out2").exists() && !base.join("in/x.designcraft").exists()); + + // Inside the write root every one of them works, and the files appear. + for (command, key) in &writes { + let target = if *key == "dir" { inside(&format!("dir-{command}")) } else { inside(&format!("out-{command}")) }; + run_ok(&mut server, command, json!({*key: target})); + } + for tool in ["export_png", "render_page"] { + ok(&mut server, tool, json!({"path": inside(&format!("{tool}.png"))})); + assert!(base.join("out").join(format!("{tool}.png")).exists(), "{tool}"); + } + for file in ["out-file.save", "out-file.exportPdf", "out-file.exportIdml", "out-book.exportPdf", "out-swatch.save", "out-snippet.export"] { + assert!(base.join("out").join(file).exists(), "{file}"); + } + assert!(base.join("out/dir-file.package/Instructions.txt").exists() && base.join("out/dir-links.copyTo/pic.png").exists()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn one_root_grants_only_its_own_access() { + let (mut read_only, base) = confined("read-only", true, false); + ok(&mut read_only, "open_document", json!({"path": s(&base.join("in/doc.designcraft"))})); + run_ok(&mut read_only, "select.firstAbove", json!({})); + for (command, key) in [("file.save", "path"), ("file.exportPdf", "path"), ("file.package", "dir"), ("file.exportText", "path")] { + refused(&mut read_only, command, json!({key: s(&base.join("out/x"))}), "write authority is absent"); + } + for tool in ["save_document", "export_png", "render_page"] { + let (error, text) = call(&mut read_only, tool, json!({"path": s(&base.join("out/x.png"))})); + assert!(error && text.contains("write authority is absent"), "{tool}: {text}"); + } + // Calls that return bytes instead of writing a path keep working. + ok(&mut read_only, "render_page", json!({"scale": 0.25})); + run_ok(&mut read_only, "file.exportPdf", json!({})); + assert!(std::fs::read_dir(base.join("out")).unwrap().next().is_none()); + let _ = std::fs::remove_dir_all(base); + + let (mut write_only, base) = confined("write-only", false, true); + for (command, key) in + [("file.open", "path"), ("file.place", "path"), ("swatch.load", "path"), ("data.source.select", "path"), ("file.recovery.list", "dir")] + { + refused(&mut write_only, command, json!({key: s(&base.join("in/doc.designcraft"))}), "read authority is absent"); + } + let (error, text) = call(&mut write_only, "place_image", json!({"path": s(&base.join("in/pic.png"))})); + assert!(error && text.contains("read authority is absent"), "{text}"); + ok(&mut write_only, "save_document", json!({"path": s(&base.join("out/x.designcraft"))})); + assert!(base.join("out/x.designcraft").exists()); + let _ = std::fs::remove_dir_all(base); +} + +#[cfg(unix)] +#[test] +fn links_out_of_a_root_are_refused() { + let (mut server, base) = confined("links", true, true); + std::os::unix::fs::symlink(base.join("outside/doc.designcraft"), base.join("in/leak.designcraft")).unwrap(); + std::os::unix::fs::symlink(base.join("outside/data.csv"), base.join("in/leak.csv")).unwrap(); + std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).unwrap(); + std::os::unix::fs::symlink(base.join("outside/gone.designcraft"), base.join("out/dangling.designcraft")).unwrap(); + refused(&mut server, "file.open", json!({"path": s(&base.join("in/leak.designcraft"))}), "outside the read root"); + refused(&mut server, "data.source.select", json!({"path": s(&base.join("in/leak.csv"))}), "outside the read root"); + refused(&mut server, "file.saveAs", json!({"path": s(&base.join("out/away/x.designcraft"))}), "outside the write root"); + refused(&mut server, "file.exportPdf", json!({"path": s(&base.join("out/away/x.pdf"))}), "outside the write root"); + refused(&mut server, "file.saveAs", json!({"path": s(&base.join("out/dangling.designcraft"))}), "a link that leads nowhere"); + assert!(!base.join("outside/x.designcraft").exists() && !base.join("outside/x.pdf").exists() && !base.join("outside/gone.designcraft").exists()); + assert_eq!(pages(&mut server), 1, "nothing from the file the link led to reached the session"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn without_roots_nothing_is_confined() { + let base = std::env::temp_dir().join(format!("dc-mcp-roots-none-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + designcraft_file(&base.join("a.designcraft"), 3, "Anywhere"); + let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap()))); + ok(&mut server, "open_document", json!({"path": s(&base.join("a.designcraft"))})); + assert_eq!(pages(&mut server), 3); + ok(&mut server, "save_document", json!({"path": s(&base.join("b.designcraft"))})); + ok(&mut server, "export_png", json!({"path": s(&base.join("b.png"))})); + assert!(base.join("b.designcraft").exists() && base.join("b.png").exists()); + let _ = std::fs::remove_dir_all(base); +} From 89399250750c0af1bb2023b6a770883496400a84 Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 10:57:59 -0400 Subject: [PATCH 009/128] CLI: add --automation-read-root and --automation-write-root to mcp --- apps/designcraft-cli/src/automation_args.rs | 90 +++++++++++++++ apps/designcraft-cli/src/main.rs | 39 +++++-- apps/designcraft-cli/tests/cli.rs | 121 ++++++++++++++++++++ 3 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 apps/designcraft-cli/src/automation_args.rs diff --git a/apps/designcraft-cli/src/automation_args.rs b/apps/designcraft-cli/src/automation_args.rs new file mode 100644 index 000000000..52ef34f20 --- /dev/null +++ b/apps/designcraft-cli/src/automation_args.rs @@ -0,0 +1,90 @@ +//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed +//! flag must not silently remove a root and leave the server unconfined. + +use std::path::PathBuf; + +pub struct AutomationArgs { + pub read_root: Option, + pub write_root: Option, + read_given: bool, + write_given: bool, +} + +impl AutomationArgs { + pub fn new(read_root: Option, write_root: Option) -> Self { + Self { read_root, write_root, read_given: false, write_given: false } + } + + /// Returns whether this argument is a root flag. A second occurrence of a flag is an error, + /// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed. + pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result { + let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value))); + let (root, given) = match flag { + "--automation-read-root" => (&mut self.read_root, &mut self.read_given), + "--automation-write-root" => (&mut self.write_root, &mut self.write_given), + _ => return Ok(false), + }; + if *given { + return Err(format!("{flag} given twice")); + } + let value = match inline { + Some(value) => value.to_owned(), + None => { + let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?; + if value.starts_with('-') { + return Err(format!("{flag} needs a folder; use {flag}= for a folder starting with '-'")); + } + value + } + }; + if value.is_empty() { + return Err(format!("{flag} needs a non-empty folder")); + } + *root = Some(value.into()); + *given = true; + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roots_are_taken_once_and_other_arguments_are_left() { + let mut roots = AutomationArgs::new(None, None); + let mut args = ["a folder".to_owned(), "doc.designcraft".to_owned()].into_iter(); + assert!(roots.consume("--automation-read-root", &mut args).unwrap()); + assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); + assert!(!roots.consume("--sample", &mut args).unwrap()); + assert_eq!(roots.read_root, Some("a folder".into())); + assert_eq!(roots.write_root, Some("out".into())); + assert_eq!(args.next().as_deref(), Some("doc.designcraft")); + assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); + assert_eq!(roots.read_root, Some("a folder".into())); + } + + #[test] + fn invalid_operands_leave_roots_unchanged() { + for flag in ["--automation-read-root", "--automation-write-root"] { + for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] { + let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into())); + let mut args = operand.map(str::to_owned).into_iter(); + assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); + assert_eq!(roots.read_root, Some("read".into())); + assert_eq!(roots.write_root, Some("write".into())); + } + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); + } + } + + #[test] + fn explicit_dash_folder_and_relative_dash_folder_are_valid() { + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap()); + assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap()); + assert_eq!(roots.read_root, Some("-folder".into())); + assert_eq!(roots.write_root, Some("./-folder".into())); + } +} diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index c3808f24b..80ee7915f 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -9,7 +9,8 @@ //! # run a command script (crates/engine/src/script.rs): `$N.path` references //! designcraft-cli app [--port PORT] COMMAND [JSON] # run a command in the running app (designcraft --control PORT) //! designcraft-cli app [--port PORT] --method METHOD [JSON] # any control-channel method (ui.screenshot, ui.render, …) -//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md) +//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR] +//! # MCP server over stdio (docs/mcp.md); the roots confine the files it reads and writes //! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document //! designcraft-cli bench FILE [--runs N] # the same measurements on one document //! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks @@ -30,13 +31,14 @@ macro_rules! outln { }}; } +mod automation_args; mod perf; use designcraft_engine::Session; use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). -const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; +const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; /// The links line under the usage block. fn usage_footer() -> String { @@ -129,16 +131,31 @@ fn validate_pdf(path: Option<&str>) -> Result<(), String> { /// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app /// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. fn mcp(args: &[String]) -> Result<(), String> { + use designcraft_engine::file_access::{self, AutomationRoots}; use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr}; let mut connect: Option = None; let mut sample = false; - let mut it = args.iter(); + let mut roots_args = automation_args::AutomationArgs::new(None, None); + let mut it = args.iter().cloned(); while let Some(a) = it.next() { - match a.as_str() { - "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?), - "--sample" => sample = true, - other => return Err(format!("unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample])")), + if roots_args.consume(&a, &mut it)? { + continue; } + match a.as_str() { + "--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?), + "--sample" => sample = true, + other => { + return Err(format!( + "unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR])" + )); + } + } + } + let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?; + if roots.is_some() && connect.is_some() { + return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \ +so drop --connect (or use --connect without the roots)" + .into()); } let backend: Box = match connect { Some(c) => { @@ -150,9 +167,17 @@ fn mcp(args: &[String]) -> Result<(), String> { } None => { let mut h = Headless::with_document(); + // `--sample` is the operator's own choice, made before the roots come into force. if sample { h.session.execute("file.newSample", &json!({})).map_err(|e| e.to_string())?; } + if let Some(roots) = roots { + // Every thread of this process, not only the server's: nothing reaches past the roots. + file_access::confine_process(roots.clone()); + let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + eprintln!("designcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); + h = h.with_automation_roots(Some(roots)); + } Box::new(h) } }; diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index c2804be94..fd96b89bc 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -154,3 +154,124 @@ fn raster_export_preserves_an_existing_reader_and_private_symlink_target() { assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600); assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); } + +/// Send `calls` (tool name, arguments) to `designcraft-cli mcp `; returns whether it exited +/// cleanly, its stderr, and the stdout lines after `initialize`, one per call. +fn mcp_session(args: &[&str], calls: &[(&str, serde_json::Value)]) -> (bool, String, Vec) { + let mut child = cli().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); + for (i, (name, arguments)) in calls.iter().enumerate() { + let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}}); + writeln!(stdin, "{call}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect(); + (out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies) +} + +fn text(reply: &serde_json::Value) -> &str { + reply["result"]["content"][0]["text"].as_str().unwrap() +} + +/// A `.designcraft` file with one page, made with the CLI itself. +fn make_document(path: &std::path::Path) { + let out = cli().args(["run", "--cmd", "file.new={}", "--cmd"]).arg(format!("file.save={}", serde_json::json!({"path": path}))).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(path.exists()); +} + +#[test] +fn mcp_automation_roots_confine_the_server() { + let base = std::env::temp_dir().join(format!("dc-cli-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (work, elsewhere) = (base.join("work"), base.join("elsewhere")); + std::fs::create_dir_all(&work).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + make_document(&work.join("in.designcraft")); + make_document(&elsewhere.join("secret.designcraft")); + let work_s = work.to_str().unwrap(); + let secret = elsewhere.join("secret.designcraft").to_str().unwrap().to_string(); + + // Both spellings of the flags; inside works, outside is refused with a readable message. + let read_flag = format!("--automation-read-root={work_s}"); + let calls = [ + ("open_document", serde_json::json!({"path": work.join("in.designcraft").to_str().unwrap()})), + ("open_document", serde_json::json!({"path": secret})), + ("save_document", serde_json::json!({"path": work.join("saved.designcraft").to_str().unwrap()})), + ("save_document", serde_json::json!({"path": elsewhere.join("saved.designcraft").to_str().unwrap()})), + ("export_png", serde_json::json!({"path": elsewhere.join("page.png").to_str().unwrap()})), + ("render_page", serde_json::json!({"path": elsewhere.join("render.png").to_str().unwrap()})), + ]; + let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls); + assert!(success, "{stderr}"); + assert!(stderr.contains("files confined"), "{stderr}"); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + assert_eq!(replies[1]["result"]["isError"], true); + assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1])); + assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]); + assert!(work.join("saved.designcraft").exists()); + for reply in &replies[3..] { + assert_eq!(reply["result"]["isError"], true, "{reply}"); + assert!(text(reply).contains("outside the write root"), "{}", text(reply)); + } + for name in ["saved.designcraft", "page.png", "render.png"] { + assert!(!elsewhere.join(name).exists(), "{name} was written outside the write root"); + } + + // A root left out grants none of its access. + let save = [("new_document", serde_json::json!({})), calls[2].clone()]; + let (_, _, replies) = mcp_session(&[&read_flag], &save); + assert_eq!(replies[1]["result"]["isError"], true); + assert!(text(&replies[1]).contains("write authority is absent"), "{}", text(&replies[1])); + let (_, _, replies) = mcp_session(&["--automation-write-root", work_s], &calls[..1]); + assert_eq!(replies[0]["result"]["isError"], true); + assert!(text(&replies[0]).contains("read authority is absent"), "{}", text(&replies[0])); + + // No flags: nothing is confined. + let (success, _, replies) = mcp_session(&[], &calls[1..2]); + assert!(success); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn mcp_automation_root_flags_are_checked_up_front() { + let base = std::env::temp_dir().join(format!("dc-cli-roots-args-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let dir = base.to_str().unwrap(); + let missing = base.join("missing"); + let fails = |args: &[&str], needle: &str| { + let mut full = vec!["mcp"]; + full.extend_from_slice(args); + let o = cli().args(&full).output().unwrap(); + assert!(!o.status.success(), "{args:?}"); + let err = String::from_utf8_lossy(&o.stderr).into_owned(); + assert!(err.contains(needle), "{args:?}: {err}"); + }; + // The flags confine the in-process server, so they cannot be combined with --connect. + fails(&["--connect", "7979", "--automation-read-root", dir], "--connect"); + fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect"); + fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice"); + fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice"); + fails(&["--automation-read-root", dir, "--automation-read-root=x"], "given twice"); + fails(&["--automation-read-root"], "needs a folder"); + fails(&["--automation-write-root", "--sample"], "needs a folder"); + fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); + fails(&["--automation-write-root="], "needs a non-empty folder"); + // A file is not a folder. + std::fs::write(base.join("f.txt"), "x").unwrap(); + fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn usage_lists_the_automation_root_flags() { + let out = cli().arg("--help").output().unwrap(); + let usage = String::from_utf8_lossy(&out.stdout); + assert!(usage.contains("--automation-read-root DIR") && usage.contains("--automation-write-root DIR"), "{usage}"); +} From 3650f36927981fffde385d60a8c813f876354e6d Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 10:57:59 -0400 Subject: [PATCH 010/128] Docs: document --automation-read-root and --automation-write-root for mcp --- docs/mcp.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/docs/mcp.md b/docs/mcp.md index 17c03a3df..20447fd02 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -41,6 +41,71 @@ claude mcp add designcraft -- "C:\Program Files\DesignCraft\designcraft-cli.exe" claude mcp add designcraft -- designcraft-cli mcp ``` +## Confining file access + +```sh +designcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +``` + +```json +{"mcpServers": {"designcraft": {"command": "designcraft-cli", + "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} +``` + +The two flags (VectorCraft, PhotoCraft and the other Craft apps take the same ones, so one client +config works across the suite) keep the agent to the folders you give it: + +- Every file a command reads must lie inside the **read root**: `open_document`, `file.open`, + `file.openIdml`, `place_image` and `file.place` / `place.load` / `media.options` with a `path`, + `links.relink`, `links.relinkFolder`, `color.loadProfile`, `swatch.load`, `library.open`, + `book.open`, `data.source.select`, `data.merge` and `file.importXml` / `xml.loadDtd` with a `path`, + and `file.recovery.list` / `file.recovery.open` with a `dir`. A DesignCraft document names more + files than the agent does: linked images, data sources, book chapters, the `Document Fonts` folder + beside a document, an IDML package's linked images and the files an image cell of a merge names. + Those are read through the same checks, so a path in a document that leads outside the read root + reads as a file that isn't there. +- Every file a command writes must lie inside the **write root**: `save_document`, `export_png`, + `render_page` with a `path`, `file.save`, `file.saveAs`, `file.saveACopy`, `file.exportPdf`, + `file.exportEpub`, `file.exportFixedEpub`, `file.exportHtml`, `file.exportText`, `file.exportXml`, + `file.exportIdml`, `file.printBooklet`, `snippet.export`, `swatch.save`, `library.new` and the + library's saves, `book.new`, `book.add`, `book.paginate`, `book.syncStyles`, `book.exportPdf`, + `file.package`, `links.copyTo` and `file.recovery.save` / `file.recovery.open` with a `dir` + (reopening removes the entries it read). `file.package` and `links.copyTo` check their folder + before they create anything. +- Read and write access are separate. Give the same folder twice for both. A root left out grants + none of its access: with only `--automation-read-root` nothing can be written to disk. Without + either flag nothing changes. +- A path is made absolute against the server's working directory, then its links are followed: a + file (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, + its nearest existing folder's links are followed and the rest must be plain names, so `..` below a + folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would + create its target). The result is compared with the root by whole folder names: `/work2/x` is not + inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like + drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused. +- A refused path is an ordinary tool error the agent can read and correct, for example + `automation path rejected: outside the write root /work/project/out: /etc/x.designcraft` or + `automation filesystem access is not granted: write authority is absent: /work/project/out/x.designcraft`. +- The roots must be existing folders; a missing one stops the server with an error. Each flag is + given once (`--flag ` or `--flag=`). + +The flags confine the in-process server, so `--connect` together with them is an error: the server +would otherwise talk to an app that isn't confined. DesignCraft's desktop app has no equivalent flags +yet, so a running app (`designcraft --control`) is not confined. `--sample` is the operator's own +choice and is opened before the roots apply. + +What the roots do not cover: commands that take no path still read and change the open document, +and `inspect_document` returns it. The system font scan, the installed fonts that `file.package` +copies, and the app's own recovery folder are fixed paths, not agent input, and are not checked. A +file inside a `Document Fonts` folder is read as a font even when it is a link that leads out of +the read root (it is not returned to the agent, only parsed as a font and skipped when it isn't +one). `file.print` hands a PDF to the system's print spooler, which is not a path the agent names. +Anything that reads or writes a path goes through one place in the engine (the `io` module); a test +keeps the rest of the engine from touching the file system on its own. + +The checks hold for every thread of the server process. A path is checked, then opened: another +program that swaps a link in between can still win that race, so the roots keep an agent to the +files it was given rather than guarding against other software on the computer. + ## Modes | | Headless (`mcp`) | Connected (`mcp --connect PORT` or `HOST:PORT`) | From d66f2c47ae1f2227879677be764127c8399e1708 Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 16:50:04 -0400 Subject: [PATCH 011/128] Engine: route book chapters and atomic saves through the checked writers, and take file access out of fonts, IDML and ICC loading Rebased onto the atomic save work: book chapter replacement, canonicalize and the writability probe, and every command write now check the roots before they reach crate::atomic. IDML import no longer reads linked images itself (the engine passes its checked reader), load_icc_file is gone (profiles arrive as bytes), the script loader goes through the host reader, and FontDb::load_document_fonts asks a caller-supplied check about the folder and each font file, so a link that leads out of the read root is skipped. --- crates/color/src/cms/mod.rs | 7 ---- crates/color/src/cms/tests.rs | 6 +-- crates/compose/src/tests.rs | 4 +- crates/engine/src/atomic.rs | 5 --- crates/engine/src/cmd/book.rs | 16 +++----- crates/engine/src/cmd/book/safety_tests.rs | 4 +- crates/engine/src/cmd/file.rs | 10 ++--- crates/engine/src/io.rs | 34 ++++++++++++++++- crates/engine/src/tests_roots.rs | 29 ++++++++++++-- crates/fonts/src/fontdb.rs | 33 +++++++++++----- crates/fonts/src/tests_docfonts.rs | 44 +++++++++++++++++----- crates/idml/src/import.rs | 22 ++++------- crates/ui-egui/src/panels/library.rs | 6 ++- 13 files changed, 142 insertions(+), 78 deletions(-) diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 2294d764e..ac7c45874 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -230,13 +230,6 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result Result { - let bytes = std::fs::read(path).map_err(|e| CmsError::Io(format!("{}: {e}", path.display())))?; - register_icc(&bytes, None) -} - /// A colour-management context: resolved working spaces plus conversion settings. #[derive(Clone)] pub struct Cms { diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 4e4f1d8b7..be6676d7b 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -188,10 +188,8 @@ fn bad_icc_rejected() { #[test] fn loads_system_cmyk_icc() { let path = std::path::Path::new("/System/Library/ColorSync/Profiles/Generic CMYK Profile.icc"); - if !path.exists() { - return; - } - let info = load_icc_file(path).unwrap(); + let Ok(bytes) = std::fs::read(path) else { return }; + let info = register_icc(&bytes, None).unwrap(); assert_eq!(info.kind, ProfileKind::Cmyk); assert!(profiles().iter().any(|p| p.name == info.name && !p.builtin)); let c = Cms::new(&ColorSettings { cmyk: info.name.clone(), ..Default::default() }).unwrap(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..c275fef91 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1889,7 +1889,7 @@ fn each_document_shapes_words_in_its_own_fonts() { let folder = root.join(name); std::fs::create_dir_all(&folder).unwrap(); std::fs::write(folder.join("word.ttf"), font_with_glyph(FAMILY, &['a', 'b', ' '], glyph).unwrap()).unwrap(); - scopes.push((designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope, glyph)); + scopes.push((designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|_| Ok(())).scope, glyph)); } let text = "ab ab ab ab"; let mut faces = Vec::new(); @@ -1919,7 +1919,7 @@ fn composite_fonts_draw_from_the_documents_fonts() { let folder = std::env::temp_dir().join(format!("dc-compose-composite-docfont-{}", std::process::id())); std::fs::create_dir_all(&folder).unwrap(); std::fs::write(folder.join("digits.ttf"), font_with_glyph(FAMILY, &['1'], 'X').unwrap()).unwrap(); - let scope = FontDb::global().load_document_fonts(&folder).scope; + let scope = FontDb::global().load_document_fonts(&folder, &|_| Ok(())).scope; let (mut d, sid, _) = doc_with("A1B", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); d.font_scope = scope; std::sync::Arc::make_mut(&mut d.styles).composite_fonts.push(CompositeFont { diff --git a/crates/engine/src/atomic.rs b/crates/engine/src/atomic.rs index 7eed2f028..96eb2f1f5 100644 --- a/crates/engine/src/atomic.rs +++ b/crates/engine/src/atomic.rs @@ -36,11 +36,6 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { write_atomic_with(path, |f| f.write_all(bytes)) } -/// [`write_atomic`] for a command's `path` parameter: the error names the path. -pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> { - write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}"))) -} - /// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When /// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and /// the error is returned. diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 58edc6aa4..e278440e3 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -90,7 +90,7 @@ impl ChapterBatch { } fn commit(self) -> Result<()> { - self.commit_with(write_staged_chapter) + self.commit_with(|path, staged| crate::io::replace_with(path, staged)) } fn commit_with(self, mut write: impl FnMut(&std::path::Path, &std::path::Path) -> std::io::Result<()>) -> Result<()> { @@ -110,12 +110,6 @@ impl ChapterBatch { } } -#[cfg(not(target_arch = "wasm32"))] -fn write_staged_chapter(path: &std::path::Path, staged: &std::path::Path) -> std::io::Result<()> { - let mut input = std::fs::File::open(staged)?; - crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ())) -} - /// Batch commands only write closed native chapters. IDML is an imported source, and an open /// chapter's model (including unsaved edits) cannot be reconciled by a disk-only book update. #[cfg(not(target_arch = "wasm32"))] @@ -125,16 +119,16 @@ fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<() if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) { return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first"))); } - let target = std::fs::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| std::fs::canonicalize(open).is_ok_and(|open| open == target))) { + let target = crate::io::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| crate::io::canonicalize(open).is_ok_and(|open| open == target))) { return Err(bad(command, format!("chapter {path} is open; close it before updating the book"))); } - if !std::fs::metadata(&target).is_ok_and(|metadata| metadata.is_file()) { + if !crate::io::is_file(&target) { return Err(bad(command, format!("chapter {path} is not a regular file"))); } // Check both the file and its resolved parent without truncating it. Replacement needs a // writable directory even when an in-place write would be permitted. - std::fs::OpenOptions::new().write(true).open(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::io::writable(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let parent = target.parent().ok_or_else(|| bad(command, format!("chapter {path} has no parent folder")))?; tempfile::Builder::new().prefix(".designcraft-book-check-").tempfile_in(parent).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; Ok(()) diff --git a/crates/engine/src/cmd/book/safety_tests.rs b/crates/engine/src/cmd/book/safety_tests.rs index e4ec20efd..e8fe37cc2 100644 --- a/crates/engine/src/cmd/book/safety_tests.rs +++ b/crates/engine/src/cmd/book/safety_tests.rs @@ -1,4 +1,4 @@ -use super::{ChapterBatch, MAX_BATCH_CHAPTERS, write_staged_chapter}; +use super::{ChapterBatch, MAX_BATCH_CHAPTERS}; use crate::Session; use serde_json::json; @@ -26,7 +26,7 @@ fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() { let error = batch .commit_with(|path, staged| { calls += 1; - if path == second { Err(std::io::Error::other("synthetic disk full")) } else { write_staged_chapter(path, staged) } + if path == second { Err(std::io::Error::other("synthetic disk full")) } else { crate::io::replace_with(path, staged) } }) .unwrap_err() .to_string(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 587855415..64a73ab30 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { }), cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { let dir = recovery_dir(s, p, FolderAccess::Write)?; - Ok(json!({"saved": crate::recovery::save(s, &dir)?})) + Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) }), cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { // Reopening removes the entries it read. @@ -191,11 +191,9 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option) -> Result { pub fn write_file(path: impl AsRef, bytes: &[u8]) -> Result<(), String> { let path = path.as_ref(); crate::file_access::check_write(path)?; - std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) + // Replaced atomically where there is a real file system (see `atomic`). + #[cfg(not(target_arch = "wasm32"))] + let written = crate::atomic::write_atomic(path, bytes); + #[cfg(target_arch = "wasm32")] + let written = std::fs::write(path, bytes); + written.map_err(|e| format!("{}: {e}", path.display())) +} + +/// Atomically replace the file at `path` with the contents of `staged`, a private temporary file +/// the engine made itself (so it is not checked), inside the automation write root when there is +/// one. +#[cfg(not(target_arch = "wasm32"))] +pub fn replace_with(path: impl AsRef, staged: impl AsRef) -> std::io::Result<()> { + let path = path.as_ref(); + crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + let mut input = std::fs::File::open(staged)?; + crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ())) +} + +/// Could the file at `path` be opened for writing (without truncating it)? Inside the automation +/// write root when there is one. +pub fn writable(path: impl AsRef) -> std::io::Result<()> { + let path = path.as_ref(); + crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::OpenOptions::new().write(true).open(path).map(drop) +} + +/// The canonical form of `path` (links resolved), inside the automation read root when there is +/// one. +pub fn canonicalize(path: impl AsRef) -> std::io::Result { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::canonicalize(path) } /// What the file system says about `path` (links followed), inside the automation read root when diff --git a/crates/engine/src/tests_roots.rs b/crates/engine/src/tests_roots.rs index 443403b0a..04711bba9 100644 --- a/crates/engine/src/tests_roots.rs +++ b/crates/engine/src/tests_roots.rs @@ -129,11 +129,15 @@ fn a_data_source_path_stored_in_a_document_is_read_through_the_roots() { fn a_book_writes_its_chapters_only_inside_the_write_root() { let (base, roots) = folders("book"); let chapter = base.join("in/a.designcraft"); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.save", &json!({"path": p(&chapter)})).unwrap(); let second = base.join("in/b.designcraft"); - s.execute("file.saveAs", &json!({"path": p(&second)})).unwrap(); + { + // Made by another session: a book only rewrites chapters that are not open. + let mut maker = Session::new(); + maker.execute("file.new", &json!({})).unwrap(); + maker.execute("file.save", &json!({"path": p(&chapter)})).unwrap(); + maker.execute("file.saveAs", &json!({"path": p(&second)})).unwrap(); + } + let mut s = Session::new(); let before = (std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap()); confine(Some(&roots), || { s.execute("book.new", &json!({"path": p(&base.join("out/b.dcbook"))})).unwrap(); @@ -210,3 +214,20 @@ fn fonts_beside_a_document_outside_the_read_root_are_not_loaded() { assert!(warnings.iter().any(|w| w.contains("x.ttf")), "without roots the folder is read and the bad font reported: {warnings:?}"); let _ = std::fs::remove_dir_all(base); } + +#[cfg(unix)] +#[test] +fn a_font_file_that_links_out_of_the_read_root_is_skipped() { + let (base, roots) = folders("fontlink"); + std::fs::create_dir_all(base.join("in/Document Fonts")).unwrap(); + std::fs::write(base.join("outside/secret.ttf"), b"not a font").unwrap(); + std::os::unix::fs::symlink(base.join("outside/secret.ttf"), base.join("in/Document Fonts/link.ttf")).unwrap(); + let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); + let doc = p(&base.join("in/doc.designcraft")); + let (_, _, warnings) = confine(Some(&roots), || cmd::load_document_fonts(&mut d, &doc)); + assert!(warnings.iter().any(|w| w.contains("outside the read root")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("not a font")), "the file was opened: {warnings:?}"); + let (_, _, warnings) = cmd::load_document_fonts(&mut d, &doc); + assert!(warnings.iter().any(|w| w.contains("link.ttf")), "without roots the link is followed: {warnings:?}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..44a64bddc 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -963,18 +963,33 @@ impl FontDb { /// [`MAX_DOCUMENT_FONT_FILES`] (in name order), files over [`MAX_DOCUMENT_FONT_BYTES`] or past /// [`MAX_DOCUMENT_FONTS_TOTAL`] in all, and files that aren't fonts are skipped with a note. A /// missing folder loads nothing (scope 0). + /// + /// The folder is named by a document, so this crate does not trust it: `allow` (`Ok`, or why a + /// path may not be read) is asked about + /// the folder and about every font file in it before either is opened, and a path it refuses + /// is skipped with its message (the engine passes its automation-root check; a host with no + /// roots passes `&|_| Ok(())`). #[cfg(not(target_arch = "wasm32"))] - pub fn load_document_fonts(&self, folder: &std::path::Path) -> DocumentFonts { + pub fn load_document_fonts(&self, folder: &std::path::Path, allow: &dyn Fn(&std::path::Path) -> Result<(), String>) -> DocumentFonts { let mut out = DocumentFonts::default(); + if let Err(why) = allow(folder) { + out.skipped.push(why); + return out; + } let Ok(entries) = std::fs::read_dir(folder) else { return out }; - let mut files: Vec = entries - .flatten() - .map(|e| e.path()) - .filter(|p| { - let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); - matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) && p.is_file() - }) - .collect(); + let mut files: Vec = Vec::new(); + for path in entries.flatten().map(|e| e.path()) { + let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { + continue; + } + // Each file is vetted before it is touched at all, a link out of the folder included. + match allow(&path) { + Ok(()) if path.is_file() => files.push(path), + Ok(()) => {} + Err(why) => out.skipped.push(why), + } + } files.sort(); let mut total = 0u64; let mut own: Vec> = Vec::new(); diff --git a/crates/fonts/src/tests_docfonts.rs b/crates/fonts/src/tests_docfonts.rs index 525bc50b8..f2f73bca7 100644 --- a/crates/fonts/src/tests_docfonts.rs +++ b/crates/fonts/src/tests_docfonts.rs @@ -36,7 +36,7 @@ fn document_fonts_load_ahead_of_installed_ones() { let installed = db.face("DocFont Precedence", "Regular"); assert!(installed.covers('a') && matches!(installed.source, FontSource::Installed(_)), "{:?}", installed.source); - let r = db.load_document_fonts(&folder); + let r = db.load_document_fonts(&folder, &|_| Ok(())); assert_eq!((r.faces, r.skipped.len()), (1, 0), "{:?}", r.skipped); let face = db.scoped(r.scope).face("DocFont Precedence", "Regular"); assert!(face.covers('b'), "the document's face wins"); @@ -45,7 +45,7 @@ fn document_fonts_load_ahead_of_installed_ones() { assert!(db.face("DocFont Precedence", "Regular").covers('a'), "outside the document the installed face stays"); // Loading the folder again (another document beside it) shares the face, in a scope of its own. - let again = db.load_document_fonts(&folder); + let again = db.load_document_fonts(&folder, &|_| Ok(())); assert_eq!((again.faces, again.skipped.len()), (1, 0)); assert_ne!(again.scope, r.scope); assert!(Arc::ptr_eq(&db.scoped(again.scope).face("DocFont Precedence", "Regular"), &face)); @@ -64,7 +64,7 @@ fn damaged_oversized_and_other_files_are_skipped() { write_font(&folder.join("nested.ttf"), "inner.ttf", "DocFont Nested", &['n']); let db = FontDb::with_font_dirs(vec![]); - let r = db.load_document_fonts(&folder); + let r = db.load_document_fonts(&folder, &|_| Ok(())); assert_eq!(r.faces, 1); assert!(db.scoped(r.scope).has_family("DocFont Good")); assert!(!db.has_family("DocFont Good"), "the document's alone"); @@ -85,7 +85,7 @@ fn the_number_of_files_read_is_capped() { write_font(&folder, "zz-real.ttf", "DocFont Past Cap", &['z']); let db = FontDb::with_font_dirs(vec![]); - let r = db.load_document_fonts(&folder); + let r = db.load_document_fonts(&folder, &|_| Ok(())); assert_eq!((r.faces, r.scope), (0, 0)); assert!(!db.scoped(r.scope).has_family("DocFont Past Cap")); assert_eq!(r.skipped.len(), MAX_DOCUMENT_FONT_FILES + 1, "one note for each file read, one for the rest"); @@ -98,7 +98,7 @@ fn large_cjk_collections_are_within_the_limit() { // macOS's Songti collection is 67 MB; full Noto/Source Han CJK collections are larger. let folder = temp_dir("cjk-size"); std::fs::File::create(folder.join("cjk.ttc")).unwrap().set_len(200 << 20).unwrap(); - let r = FontDb::with_font_dirs(vec![]).load_document_fonts(&folder); + let r = FontDb::with_font_dirs(vec![]).load_document_fonts(&folder, &|_| Ok(())); assert_eq!(r.skipped.len(), 1, "{:?}", r.skipped); assert!(!r.skipped[0].contains("larger than"), "skipped for not being a font, not for its size: {:?}", r.skipped); let _ = std::fs::remove_dir_all(&folder); @@ -107,7 +107,7 @@ fn large_cjk_collections_are_within_the_limit() { #[test] fn a_missing_folder_loads_nothing() { let db = FontDb::with_font_dirs(vec![]); - let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER)); + let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER), &|_| Ok(())); assert_eq!((r.faces, r.scope, r.skipped.len()), (0, 0, 0)); } @@ -118,7 +118,7 @@ fn each_scope_sees_its_own_fonts_only() { write_font(&a, "a.ttf", FAMILY, &['a']); write_font(&b, "b.ttf", FAMILY, &['b', '\u{E123}']); let db = FontDb::with_font_dirs(vec![]); - let (ra, rb) = (db.load_document_fonts(&a), db.load_document_fonts(&b)); + let (ra, rb) = (db.load_document_fonts(&a, &|_| Ok(())), db.load_document_fonts(&b, &|_| Ok(()))); let (in_a, in_b) = (db.scoped(ra.scope), db.scoped(rb.scope)); assert!(in_a.face(FAMILY, "Regular").covers('a') && in_b.face(FAMILY, "Regular").covers('b')); @@ -139,16 +139,16 @@ fn a_closed_scope_finds_nothing_and_reopening_reuses_the_faces() { let folder = temp_dir("reopen"); let file = write_font(&folder, "r.ttf", FAMILY, &['r']); let db = FontDb::with_font_dirs(vec![]); - let r = db.load_document_fonts(&folder); + let r = db.load_document_fonts(&folder, &|_| Ok(())); let face = db.scoped(r.scope).face(FAMILY, "Regular"); db.close_scope(r.scope); assert!(!db.scoped(r.scope).has_family(FAMILY), "closed"); - let again = db.load_document_fonts(&folder); + let again = db.load_document_fonts(&folder, &|_| Ok(())); assert!(Arc::ptr_eq(&db.scoped(again.scope).face(FAMILY, "Regular"), &face), "an unchanged file isn't read again"); // A file changed since is read again. std::fs::write(&file, font_with(FAMILY, &['r', 's']).unwrap()).unwrap(); - let changed = db.load_document_fonts(&folder); + let changed = db.load_document_fonts(&folder, &|_| Ok(())); let new_face = db.scoped(changed.scope).face(FAMILY, "Regular"); assert!(new_face.covers('s') && new_face.id() != face.id()); assert!(!db.scoped(again.scope).face(FAMILY, "Regular").covers('s'), "the document open before keeps what it opened with"); @@ -166,3 +166,27 @@ fn restricted_licence_reads_the_embedding_bits() { assert!(!db.face(FALLBACK_FAMILY, "Regular").restricted_licence(), "the bundled fonts are OFL"); assert_eq!(db.face(FALLBACK_FAMILY, "Regular").source, FontSource::Bundled); } + +#[test] +fn the_access_check_vets_the_folder_and_every_file_before_it_is_opened() { + let folder = temp_dir("access"); + write_font(&folder, "kept.ttf", "DocFont Kept", &['k']); + write_font(&folder, "refused.ttf", "DocFont Refused", &['r']); + let db = FontDb::with_font_dirs(vec![]); + let asked = std::sync::Mutex::new(Vec::new()); + let allow = |p: &Path| { + asked.lock().unwrap().push(p.to_path_buf()); + if p.ends_with("refused.ttf") { Err("refused.ttf: outside the read root".to_string()) } else { Ok(()) } + }; + let r = db.load_document_fonts(&folder, &allow); + assert_eq!(r.faces, 1, "{:?}", r.skipped); + assert_eq!(r.skipped, vec!["refused.ttf: outside the read root".to_string()]); + let asked = asked.into_inner().unwrap(); + assert_eq!(asked.first().map(PathBuf::as_path), Some(folder.as_path()), "the folder is asked about first"); + assert!(asked.iter().any(|p| p.ends_with("kept.ttf")) && asked.iter().any(|p| p.ends_with("refused.ttf"))); + + // A refused folder is not even listed. + let r = db.load_document_fonts(&folder, &|_| Err("no".to_string())); + assert_eq!((r.faces, r.scope, r.skipped), (0, 0, vec!["no".to_string()])); + let _ = std::fs::remove_dir_all(&folder); +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..41c79f0f7 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -29,21 +29,11 @@ fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option< Some(designcraft_doc::CellStroke { weight: first.weight?, color: first.color.clone()?, tint: first.tint?, kind: first.kind.clone()? }) } -/// Import an IDML package. Linked images are read from disk when available (not on wasm). +/// Import an IDML package without reading its linked images from disk: this crate never touches +/// the file system, so a caller that wants the links brings its own reader +/// ([`import_idml_with`]). The engine passes one that checks the automation roots. pub fn import_idml(bytes: &[u8]) -> Result { - import_idml_with(bytes, &default_reader) -} - -fn default_reader(path: &str) -> Option> { - #[cfg(not(target_arch = "wasm32"))] - { - std::fs::read(path).ok() - } - #[cfg(target_arch = "wasm32")] - { - let _ = path; - None - } + import_idml_with(bytes, &|_| None) } /// Import with a custom reader for linked files (`path` → bytes). @@ -68,7 +58,9 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option = HashMap::new(); for i in 0..zip.len() { let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?; - if f.is_dir() { + // A folder entry of the archive (the zip crate's own `is_dir`; spelled out so no file-system + // probe appears in this crate). + if f.name().ends_with('/') { continue; } let name = f.name().to_string(); diff --git a/crates/ui-egui/src/panels/library.rs b/crates/ui-egui/src/panels/library.rs index ccaa595dd..c64a61b26 100644 --- a/crates/ui-egui/src/panels/library.rs +++ b/crates/ui-egui/src/panels/library.rs @@ -195,8 +195,10 @@ pub fn scripts(app: &mut DesignApp, ui: &mut egui::Ui) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load…"))).clicked() && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("script")) { - #[cfg(not(target_arch = "wasm32"))] - match std::fs::read_to_string(&path) { + // Through the host's reader, like every other file the UI opens: this crate has no + // file system of its own. + let read = app.services.read.as_mut().map_or_else(|| Err("no file access".to_string()), |r| r(&path)); + match read.and_then(|bytes| String::from_utf8(bytes).map_err(|e| e.to_string())) { Ok(text) => { let name = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Script".into()); app.ui.scripts.push((name, text)); From 5473f42de4c0d017f069b8d4d44ae1007e4a11d9 Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 16:50:04 -0400 Subject: [PATCH 012/128] Engine: guard test flags bare fs calls and Path probes, and scans every crate that reads paths The scan now reads crates/*/src instead of only the engine, and flags fs:: after use std::fs or use std::{fs, ...}, std::fs, File::open/create, OpenOptions, direct atomic writes and the Path probes (metadata, symlink_metadata, canonicalize, read_dir, try_exists, exists, is_file, is_dir, read_link). The files it leaves out are listed with the reason for each, and a second test fails when an entry no longer needs its exception. --- crates/engine/src/file_access.rs | 76 -------- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_file_guard.rs | 250 ++++++++++++++++++++++++++ docs/mcp.md | 13 +- 4 files changed, 260 insertions(+), 81 deletions(-) create mode 100644 crates/engine/src/tests_file_guard.rs diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 03ffd7c94..4f9a17c6f 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -436,80 +436,4 @@ mod tests { assert!(!is_device_name(f), "{f}"); } } - - /// Every file a command reads or writes by path goes through `io` (`read_file`, - /// `write_file`, `metadata`, `read_dir`...), which check the roots. Engine code that reaches - /// for `std::fs` itself would bypass them, so none may: every file under `src/` (commands, - /// `lib.rs`, `guard.rs`...) except - /// - /// - `io.rs` and this module, the checked gateway, - /// - `recovery.rs`, which keeps the unsaved documents in a folder the host application - /// chooses (a command's own `dir` is checked in `cmd/file.rs` before it gets there), - /// - `cmd/package.rs`, whose `OutputRoot` checks the folder against the write root before it - /// creates anything and then only writes inside it. Its reads are of the font files that - /// draw the document's text (installed fonts and the `Document Fonts` folder), never of a - /// path a command names, - /// - the test files. - #[test] - fn commands_do_not_touch_the_file_system_themselves() { - fn walk(dir: &Path, files: &mut Vec) { - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - walk(&path, files); - } else if path.extension().is_some_and(|e| e == "rs") { - files.push(path); - } - } - } - /// The file's lines but its test modules (which build their own fixtures): a module is - /// skipped from its `#[cfg(test)]` to the closing brace at the same indent. - fn code_outside_tests(text: &str) -> Vec<(usize, &str)> { - let lines: Vec<&str> = text.lines().collect(); - let mut code = vec![]; - let mut i = 0; - while i < lines.len() { - let attr = lines[i].trim(); - let module = lines.get(i + 1).map(|l| l.trim_start().trim_start_matches("pub ")); - if attr.starts_with("#[cfg(") && attr.contains("test") && module.is_some_and(|m| m.starts_with("mod ")) { - let header = lines[i + 1]; - i += 2; - if header.trim_end().ends_with('{') { - let indent = &header[..header.len() - header.trim_start().len()]; - let close = format!("{indent}}}"); - while i < lines.len() && lines[i].trim_end() != close { - i += 1; - } - i += 1; - } - continue; - } - code.push((i + 1, lines[i])); - i += 1; - } - code - } - let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = vec![]; - walk(&src, &mut files); - assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); - let mut offenders = vec![]; - for path in files { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - let rel = path.strip_prefix(&src).unwrap_or(&path).to_string_lossy().replace('\\', "/"); - if matches!(rel.as_str(), "io.rs" | "file_access.rs" | "recovery.rs" | "cmd/package.rs") || name.starts_with("tests") { - continue; - } - let text = std::fs::read_to_string(&path).unwrap(); - for (n, line) in code_outside_tests(&text) { - let line = line.trim_start(); - let touches = - ["std::fs", "File::open", "File::create", "OpenOptions", ".is_file()", ".is_dir()", ".exists()"].iter().any(|w| line.contains(w)); - if !line.starts_with("//") && touches { - offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); - } - } - } - assert!(offenders.is_empty(), "use crate::io (read_file, write_file, metadata, read_dir...): {offenders:#?}"); - } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1f06cbade..e42ebced3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -558,6 +558,8 @@ pub(crate) use push_undo as record_undo; #[cfg(test)] mod tests; #[cfg(test)] +mod tests_file_guard; +#[cfg(test)] mod tests_idml; #[cfg(test)] mod tests_roots; diff --git a/crates/engine/src/tests_file_guard.rs b/crates/engine/src/tests_file_guard.rs new file mode 100644 index 000000000..21c8f301d --- /dev/null +++ b/crates/engine/src/tests_file_guard.rs @@ -0,0 +1,250 @@ +//! The guarantee behind `--automation-read-root` and `--automation-write-root`: every file a +//! command reads or writes by path goes through a checked reader, in every crate a command can +//! reach, not only this one. +//! +//! In the engine that reader is the [`crate::io`] module. The lower crates cannot depend on the +//! engine (`cargo xtask layers`), so they either have no file access at all or take a reader or +//! an access check from the caller (`designcraft_idml::import_idml_with`, +//! `designcraft_fonts::FontDb::load_document_fonts`). +//! +//! The scan reads the source of every crate under `crates/` and flags what touches the file +//! system, however it is imported or spelled: `std::fs`, a bare `fs::` after `use std::fs` or +//! `use std::{fs, ...}`, `File::open`/`File::create`, `OpenOptions`, and the `Path` probes +//! (`.metadata(`, `.exists(`, `.canonicalize(`, `.read_dir(`...), as methods or as +//! `Path::exists`. The host applications under `apps/` are out of scope: they run with the +//! operator's own authority, and the roots are applied inside the process they start. + +use std::path::{Path, PathBuf}; + +/// Files that may touch the file system, with why. Paths are relative to `crates/`. A file not +/// listed here, and not a test file or test module, must not. +const ALLOWED: &[(&str, &str)] = &[ + ("engine/src/io.rs", "the checked gateway: every read and write a command makes"), + ("engine/src/file_access.rs", "the root check itself (resolves links with the file system)"), + ("engine/src/atomic.rs", "how a write reaches the disk; commands call it only through io::write_file and io::replace_with"), + ( + "engine/src/recovery.rs", + "the unsaved documents live in a folder the host application chooses (a command's own `dir` is checked in cmd/file.rs before it gets here)", + ), + ( + "engine/src/cmd/package.rs", + "`OutputRoot` checks the folder against the write root before it creates anything and then only writes inside it. Its reads are of the font files that draw the document's text (installed fonts and the `Document Fonts` folder), never of a path a command names", + ), + ( + "fonts/src/fontdb.rs", + "the installed-font scan reads the platform's fixed font folders (`system_font_dirs`: no command parameter or document field names one), and `load_document_fonts` asks the caller's `allow` check about its folder and each font file before opening either", + ), + ( + "mcp/src/tools.rs", + "`screenshot` reads back the PNG the connected app just wrote. Connected mode cannot be combined with the roots (the CLI refuses the pair), and the app's own engine checks the path it writes", + ), +]; + +fn rust_files(dir: &Path, files: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + rust_files(&path, files); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } +} + +/// Test files (`tests.rs`, `tests_*.rs`, `*_tests.rs`, anything under a `tests/` folder) build +/// their own fixtures on disk. +fn is_test_file(rel: &str) -> bool { + let name = rel.rsplit('/').next().unwrap_or(rel); + name.starts_with("tests") || name.ends_with("_tests.rs") || name == "testing.rs" || rel.split('/').any(|part| part == "tests") +} + +/// The file's lines but its test modules: a module is skipped from its `#[cfg(test)]` (or +/// `#[cfg(all(test, ...))]`) to the closing brace at the same indent. A `mod x;` declaration +/// has no body to skip. +fn code_outside_tests(text: &str) -> Vec<(usize, &str)> { + let lines: Vec<&str> = text.lines().collect(); + let mut code = vec![]; + let mut i = 0; + while i < lines.len() { + let attr = lines[i].trim(); + let module = lines.get(i + 1).map(|l| l.trim_start().trim_start_matches("pub ")); + if attr.starts_with("#[cfg(") && attr.contains("test") && module.is_some_and(|m| m.starts_with("mod ")) { + let header = lines[i + 1]; + i += 2; + if header.trim_end().ends_with('{') { + let indent = &header[..header.len() - header.trim_start().len()]; + let close = format!("{indent}}}"); + while i < lines.len() && lines[i].trim_end() != close { + i += 1; + } + i += 1; + } + continue; + } + code.push((i + 1, lines[i])); + i += 1; + } + code +} + +/// `line` without its `//` comment (a `//` inside a string or char is rare enough in this code +/// to leave alone: it only hides the rest of that line from the scan). +fn without_comment(line: &str) -> &str { + line.find("//").map_or(line, |at| &line[..at]) +} + +fn is_ident(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +/// Does `line` contain `word` not glued to a longer identifier on its left (`std::fs::` and +/// ` fs::` match `fs::`; `ofs::` does not)? +fn has_word(line: &str, word: &str) -> bool { + line.match_indices(word).any(|(at, _)| !line[..at].chars().next_back().is_some_and(is_ident)) +} + +/// What in `line` reaches the file system, or `None`. +fn file_system_use(line: &str) -> Option { + let line = without_comment(line); + // `std::fs::read`, `fs::read` after `use std::fs;` or `use std::{fs, io};`, `std::os::unix::fs::...`. + for word in ["fs::", "File::open", "File::create", "File::options", "OpenOptions", "atomic::write_atomic"] { + if has_word(line, word) { + return Some(word.into()); + } + } + // The probes on a path, as `p.exists()` or `Path::exists`. + for method in ["metadata", "symlink_metadata", "canonicalize", "read_dir", "try_exists", "exists", "is_file", "is_dir", "read_link"] { + if line.contains(&format!(".{method}(")) || line.contains(&format!("Path::{method}")) { + return Some(method.to_string()); + } + } + None +} + +/// `use` statements (which can span lines) that bring the file system module in under any name: +/// `use std::{fs as files, io};` leaves no `fs::` to find at the call. +fn imports_fs(text: &str) -> Vec { + let mut hits = vec![]; + let mut statement = String::new(); + let mut start = 0; + for (n, line) in code_outside_tests(text) { + let line = without_comment(line); + if statement.is_empty() { + if !line.trim_start().starts_with("use ") && !line.trim_start().starts_with("pub use ") { + continue; + } + start = n; + } + statement.push_str(line); + statement.push(' '); + if line.contains(';') { + let words: Vec<&str> = statement.split(|c: char| !is_ident(c)).collect(); + if words.contains(&"fs") && (words.contains(&"std") || words.contains(&"tokio") || words.contains(&"async_std")) { + hits.push(start); + } + statement.clear(); + } + } + hits +} + +/// Every offending line of the crate sources under `crates_dir`, minus [`ALLOWED`] and test code. +fn offenders(crates_dir: &Path) -> (Vec, usize) { + let mut files = vec![]; + rust_files(crates_dir, &mut files); + let mut scanned = 0; + let mut found = vec![]; + for path in files { + let rel = path.strip_prefix(crates_dir).unwrap_or(&path).to_string_lossy().replace('\\', "/"); + // `crates//src/...` only: build scripts, benches and `tests/` are not shipped code. + if !rel.split('/').nth(1).is_some_and(|d| d == "src") || is_test_file(&rel) || ALLOWED.iter().any(|(allowed, _)| *allowed == rel) { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { continue }; + scanned += 1; + for (n, line) in code_outside_tests(&text) { + if let Some(what) = file_system_use(line) { + found.push(format!("crates/{rel}:{n}: `{what}`: {}", line.trim())); + } + } + for n in imports_fs(&text) { + found.push(format!("crates/{rel}:{n}: imports the file system module")); + } + } + (found, scanned) +} + +fn crates_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("..") +} + +/// Every command reads and writes files through [`crate::io`], in the engine and in every crate +/// below it: none may reach the file system itself, so the automation roots cannot be walked +/// around by a path that one of them opens. The exceptions are [`ALLOWED`], each with its reason. +#[test] +fn no_crate_touches_the_file_system_outside_the_checked_readers() { + let (found, scanned) = offenders(&crates_dir()); + assert!(scanned > 100, "the scan found only {scanned} source files: has the layout moved?"); + assert!( + found.is_empty(), + "route these through crate::io (engine) or a reader the caller injects (lower crates), or add the file to ALLOWED with the reason it is safe: {found:#?}" + ); +} + +/// The allow list names files that exist, so a rename cannot leave a stale exception behind (or +/// quietly exempt a new file of the same name). +#[test] +fn every_allowed_file_exists_and_still_needs_its_exception() { + for (rel, reason) in ALLOWED { + let path = crates_dir().join(rel); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{rel}: {e}")); + assert!(!reason.is_empty()); + let touches = code_outside_tests(&text).iter().any(|(_, line)| file_system_use(line).is_some()) || !imports_fs(&text).is_empty(); + assert!(touches, "{rel} no longer touches the file system: drop it from ALLOWED"); + } +} + +/// The scan must not be foolable by how a call is spelled. +#[test] +fn the_scan_sees_every_spelling() { + let flagged = [ + "let b = std::fs::read(p)?;", + "let b = fs::read(p)?;", + " fs::write(p, b)", + "let f = File::open(p)?;", + "let f = std::fs::File::create(p)?;", + "let f = OpenOptions::new().write(true).open(p)?;", + "let m = path.metadata()?;", + "let m = path.symlink_metadata()?;", + "let c = path.canonicalize()?;", + "for e in path.read_dir()? {}", + "if path.try_exists()? {}", + "if p.exists() {}", + "if p.is_file() {}", + "if p.is_dir() {}", + "let l = p.read_link()?;", + "files.retain(Path::exists);", + "crate::atomic::write_atomic(p, b)?;", + "(a, fs::read(x))", + ]; + for line in flagged { + assert!(file_system_use(line).is_some(), "not flagged: {line}"); + } + let fine = [ + "let b = crate::io::read_file(p)?;", + "if crate::io::is_dir(p) {}", + "let m = crate::io::metadata(p)?;", + "// std::fs::read(p) is what io wraps", + "let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);", + "let ofs::x = 1;", + ]; + for line in fine { + assert!(file_system_use(line).is_none(), "wrongly flagged: {line}"); + } + for import in ["use std::fs;", "use std::{fs, io};", "use std::{\n io,\n fs as files,\n};", "use std::fs::File;"] { + assert!(!imports_fs(import).is_empty(), "import not flagged: {import}"); + } + assert!(imports_fs("use std::io::Read;\nuse std::path::Path;").is_empty()); + assert!(imports_fs("#[cfg(test)]\nmod tests {\n use std::fs;\n}\n").is_empty(), "test modules are skipped"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 20447fd02..f69905390 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -96,11 +96,14 @@ choice and is opened before the roots apply. What the roots do not cover: commands that take no path still read and change the open document, and `inspect_document` returns it. The system font scan, the installed fonts that `file.package` copies, and the app's own recovery folder are fixed paths, not agent input, and are not checked. A -file inside a `Document Fonts` folder is read as a font even when it is a link that leads out of -the read root (it is not returned to the agent, only parsed as a font and skipped when it isn't -one). `file.print` hands a PDF to the system's print spooler, which is not a path the agent names. -Anything that reads or writes a path goes through one place in the engine (the `io` module); a test -keeps the rest of the engine from touching the file system on its own. +`Document Fonts` folder and each font file in it are checked before they are opened, so a link that +leads out of the read root is skipped with a note. `file.print` hands a PDF to the system's print +spooler, which is not a path the agent names. +Anything that reads or writes a path goes through one place in the engine (the `io` module). The +crates below the engine have no file access of their own: the IDML import and the font folders take +a reader or an access check from the engine, and ICC profiles arrive as bytes. A test scans the +source of every crate for the file system, however it is imported or spelled, and lists the few +files it excludes and why. The checks hold for every thread of the server process. A path is checked, then opened: another program that swaps a link in between can still win that race, so the roots keep an agent to the From 260af3b5dfdc41476a8e9ba137db56ea0be468f4 Mon Sep 17 00:00:00 2001 From: fabian-co Date: Mon, 12 Oct 2026 17:45:45 -0500 Subject: [PATCH 013/128] macOS: open the window on Intel Metal by skipping indirect-call validation wgpu validates indirect draw and dispatch arguments with a compute shader it compiles when the device is created. Metal on an Intel Iris Pro (MacBookPro11,3, macOS 12.7.6) fails to compile it ("Compiler encountered an internal error" in MTLCompilerService), the device is lost, and the window never opens. DesignCraft draws and dispatches nothing indirectly, so leave the check out of the instance flags. WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on. VectorCraft does the same (storytold/vectorcraft#651). Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + apps/designcraft/src/main.rs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index 39fbe7100..b78267247 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ The file stops growing at 16 MiB. `--version` writes no file. |---|---| | `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). | | `WGPU_BACKEND` | The graphics backend that composites the window: `dx12`, `vulkan`, `gl` or `metal` (a comma-separated list lets wgpu choose among them). Default: DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, with OpenGL as the fallback. Setting it also turns off the start-up fallback described below. | +| `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. DesignCraft draws nothing indirectly and turns it off, since the compute shader the check needs fails to compile on some drivers (Metal on an Intel Iris Pro), which lost the device before the window opened. | The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 26ce2ee4f..d91ca40cb 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -289,6 +289,14 @@ fn main() -> eframe::Result { { create.instance_descriptor.backends = s.backends(); } + // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a + // compute shader when the device is created, one that Metal can't compile on an Intel Iris + // Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns + // the check back on. + if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + create.instance_descriptor.flags = + create.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); + } // winit has no file drag-and-drop on Wayland (only on X11), so dropping images from the file // manager showed a "no" cursor. Run through XWayland when it's there; DESIGNCRAFT_WAYLAND=1 // keeps the native Wayland backend. From 6f4fa3e56da61b4fafacdf6e01887c87711217d6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 11:14:47 +0200 Subject: [PATCH 014/128] fix(render): A group's gradient feather fades the group, on screen and in PDF A gradient feather on a group (Gradient Feather tool over grouped text, as in LYH Studio's poster tutorial) did nothing on screen: groups never went through the effects path. In PDF the group's mask was the group's own path bounds, and a group has no path, so the mask covered a box of zero size and hid the group. - Item::feather_bounds: the box the feather spans, the objects' bounds for a group. - Render: a feathered group draws its objects in one layer, then fades it with the same mask as other objects (fx::gradient_feather_mask). - PDF: the group's mask spans its objects. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 11 ++++++++++ crates/engine/src/cmd/paths.rs | 33 ++++++++++++++++++++++++++++++ crates/pdf/src/export.rs | 12 +++++++++-- crates/render/src/fx.rs | 37 ++++++++++++++++++++-------------- crates/render/src/lib.rs | 13 ++++++++++++ 6 files changed, 90 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..0e1e59ea0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -153,6 +153,7 @@ Newest first. - **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change. - **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change. - **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. +- **2026-10-12** · A gradient feather on a group fades the group as one, on screen and in PDF. Before, the screen ignored it, and PDF masked the group with a box of zero size because a group has no path of its own. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 0a701998e..80b3f2f6d 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1105,6 +1105,17 @@ impl Item { self.path.bounds().unwrap_or(Rect::ZERO) } + /// The box a gradient feather spans, in item space: the path's bounds, or a group's objects' + /// (a group has no path of its own). + pub fn feather_bounds(&self) -> Rect { + if self.is_group() + && let Content::Group { items } = &self.content + { + return items.iter().map(|c| c.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + } + self.inner_bounds() + } + /// Spread-space geometric bounds (axis-aligned box of the transformed path; groups: union). /// A group (its bounds are its children's). A frame holding pasted-into items is not. pub fn is_group(&self) -> bool { diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..976e892b9 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -435,6 +435,39 @@ mod tests { assert!(t.contains("/SMask") || t.contains("/Luminosity"), "a soft mask"); } + #[test] + fn a_groups_gradient_feather_fades_it_on_screen_and_in_pdf() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let mut ids = vec![]; + for rect in [[100, 100, 200, 200], [200, 100, 300, 200]] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + ids.push(id); + } + let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap(); + // The Gradient Feather tool's drag, then by angle (a group has no path of its own: the + // gradient spans its objects). + for p in [json!({"ids": [g], "from": [100, 150], "to": [300, 150]}), json!({"ids": [g], "angle": 0})] { + s.execute("object.gradientFeather", &p).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let (l, m, r) = (img.pixel(105, 150)[0], img.pixel(200, 150)[0], img.pixel(295, 150)[0]); + assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right on screen: {l} {m} {r}"); + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap(); + // Over white paper. + let at = |x: usize, y: usize| { + let c = pm.data()[y * pm.width() as usize + x]; + c.r as u32 + 255 - c.a as u32 + }; + let (l, m, r) = (at(105, 150), at(200, 150), at(295, 150)); + assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right in PDF: {l} {m} {r}"); + } + } + #[test] fn create_outlines_turns_text_into_paths() { let mut s = Session::new(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..3627fd954 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -757,7 +757,7 @@ impl Exporter<'_> { } // Gradient feather: a luminosity mask (white = opaque) over the object. let xf = parent * it.xf; - let (p0, p1) = gf.points(it.inner_bounds()); + let (p0, p1) = gf.points(it.feather_bounds()); let gray = |a: f32| -> krilla::color::Color { luma::Color::new((a.clamp(0.0, 1.0) * 255.0).round() as u8).into() }; let stops = vec![ Stop { offset: NormalizedF32::ZERO, color: gray(gf.start), opacity: NormalizedF32::ONE }, @@ -791,7 +791,15 @@ impl Exporter<'_> { } .into() }; - let area = it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0); + // A group's mask covers its objects (it has no path of its own), with room for text and + // strokes past their frames. + let area = if it.is_group() { + let b = it.feather_bounds(); + let pad = 36.0 + 0.25 * b.width().max(b.height()); + b.inflate(pad, pad) + } else { + it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0) + }; let mut sb = s.stream_builder(); let mut ms = sb.surface(); ms.push_transform(&tf(xf)); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6b29516f4..0e1ffd565 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -64,6 +64,27 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter { }) } +/// Keep what was drawn in the current layer as much as `it`'s gradient feather says, over `area` +/// (item space; past the gradient's ends its end opacities carry on). +pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine, area: Rect) { + let gf = &it.effects.gradient_feather; + let (p0, p1) = gf.points(it.feather_bounds()); + let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8))); + let stops = [stop(0.0, gf.start), stop(1.0, gf.end)]; + let grad = if gf.radial { + peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice()) + } else { + peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice()) + }; + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + ctx.set_transform(f.view * xf); + ctx.set_paint(grad); + ctx.fill_rect(&area); + ctx.reset_paint_transform(); + ctx.pop_layer(); +} + impl Renderer { /// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow. pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option) { @@ -170,22 +191,8 @@ impl Renderer { } } if gf.on { - let (p0, p1) = gf.points(it.inner_bounds()); - let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8))); - let stops = [stop(0.0, gf.start), stop(1.0, gf.end)]; - let grad = if gf.radial { - peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice()) - } else { - peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice()) - }; - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); - ctx.set_transform(f.view * xf); - ctx.set_paint(grad); let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { it.inner_bounds() }; - ctx.fill_rect(&area.inflate(4.0, 4.0)); - ctx.reset_paint_transform(); - ctx.pop_layer(); + gradient_feather_mask(ctx, f, it, xf, area.inflate(4.0, 4.0)); } if gf.on || directional { ctx.pop_layer(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..ea942c2c8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -476,6 +476,12 @@ impl Renderer { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); } + // Gradient feather: the group's objects drawn together, then faded as one. + let feather = it.effects.gradient_feather.on && f.opts.quality != DisplayQuality::Fast; + if feather { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, None, None, None); + } if it.knockout { // Knockout: each object first clears its area of those behind it, so it composites // with the group's backdrop rather than with its siblings. @@ -495,6 +501,13 @@ impl Renderer { self.draw_item(ctx, f, c, xf, page); } } + if feather { + // Generous: text and strokes reach past their frames. + let b = it.feather_bounds(); + let pad = 36.0 + 0.25 * b.width().max(b.height()); + fx::gradient_feather_mask(ctx, f, it, xf, b.inflate(pad, pad)); + ctx.pop_layer(); + } if layered { ctx.pop_layer(); } From 5b74bd71e445ac5818d72b3634cb224cac198408 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 18:01:17 +0200 Subject: [PATCH 015/128] fix(render): A group's gradient feather masks everything the group draws The DestIn mask now covers the whole render target (and the PDF's exported area), with the gradient kept in item space through the paint transform, since past the gradient's ends the end opacity carries on: a child's long drop shadow or thick stroke is faded, not cut off at a box. feather_bounds counts only shown children and active states, walking nested groups without recursion. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/item.rs | 49 ++++++++++++++++++++++++---- crates/engine/src/cmd/paths.rs | 58 +++++++++++++++++++++++++++++++--- crates/pdf/src/export.rs | 19 +++-------- crates/render/src/fx.rs | 14 ++++---- crates/render/src/lib.rs | 5 +-- 5 files changed, 109 insertions(+), 36 deletions(-) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 80b3f2f6d..5c08734f5 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1105,15 +1105,27 @@ impl Item { self.path.bounds().unwrap_or(Rect::ZERO) } - /// The box a gradient feather spans, in item space: the path's bounds, or a group's objects' - /// (a group has no path of its own). + /// The box a gradient feather spans, in item space: the path's bounds, or a group's visible + /// objects' (a group has no path of its own). Only the active states contribute, including in + /// nested groups. Walk iteratively so deeply nested documents don't exhaust the call stack. pub fn feather_bounds(&self) -> Rect { - if self.is_group() - && let Content::Group { items } = &self.content - { - return items.iter().map(|c| c.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + if !self.is_group() { + return self.inner_bounds(); } - self.inner_bounds() + let mut stack: Vec<_> = self.shown_children().map(|c| (c.as_ref(), c.xf)).collect(); + let mut bounds: Option = None; + while let Some((it, xf)) = stack.pop() { + if it.hidden { + continue; + } + if it.is_group() { + stack.extend(it.shown_children().map(|c| (c.as_ref(), xf * c.xf))); + } else { + let b = xf.transform_rect_bbox(it.inner_bounds()); + bounds = Some(bounds.map_or(b, |have| have.union(b))); + } + } + bounds.unwrap_or(Rect::ZERO) } /// Spread-space geometric bounds (axis-aligned box of the transformed path; groups: union). @@ -1230,6 +1242,29 @@ mod tests { assert_eq!(n, 3); } + #[test] + fn feather_bounds_follow_visible_states_in_nested_groups() { + let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0))); + let mut b = a.clone(); + b.id = ItemId(2); + b.xf = Affine::translate((500.0, 500.0)); + let mut state = Item::new(ItemId(3), LayerId(1), Shape::Group, PathData::default()); + state.content = Content::Group { items: vec![Arc::new(a), Arc::new(b)] }; + state.states = vec!["Visible".into(), "Inactive".into()]; + assert_eq!(state.feather_bounds(), Rect::new(10.0, 20.0, 30.0, 40.0)); + state.active_state = 1; + assert_eq!(state.feather_bounds(), Rect::new(510.0, 520.0, 530.0, 540.0)); + state.active_state = 0; + state.xf = Affine::translate((5.0, 10.0)) * Affine::scale(2.0); + let mut hidden = state.clone(); + hidden.id = ItemId(4); + hidden.hidden = true; + hidden.xf = Affine::translate((-1000.0, -1000.0)); + let mut outer = Item::new(ItemId(5), LayerId(1), Shape::Group, PathData::default()); + outer.content = Content::Group { items: vec![Arc::new(state), Arc::new(hidden)] }; + assert_eq!(outer.feather_bounds(), Rect::new(25.0, 50.0, 65.0, 90.0)); + } + #[test] fn serde_roundtrip() { let mut it = Item::new(ItemId(1), LayerId(1), Shape::Oval, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0))); diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 976e892b9..d587526df 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -446,15 +446,17 @@ mod tests { ids.push(id); } let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap(); + s.execute("transform.move", &json!({"ids": [g], "dx": 20, "dy": 30})).unwrap(); // The Gradient Feather tool's drag, then by angle (a group has no path of its own: the - // gradient spans its objects). - for p in [json!({"ids": [g], "from": [100, 150], "to": [300, 150]}), json!({"ids": [g], "angle": 0})] { + // gradient spans its objects). The gradient still follows the translated group even + // though the mask geometry covers the whole output. + for p in [json!({"ids": [g], "from": [120, 180], "to": [320, 180]}), json!({"ids": [g], "angle": 0})] { s.execute("object.gradientFeather", &p).unwrap(); let d = s.doc().unwrap().doc.clone(); let mut rr = designcraft_render::Renderer::new(); rr.threads = 0; let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); - let (l, m, r) = (img.pixel(105, 150)[0], img.pixel(200, 150)[0], img.pixel(295, 150)[0]); + let (l, m, r) = (img.pixel(125, 180)[0], img.pixel(220, 180)[0], img.pixel(315, 180)[0]); assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right on screen: {l} {m} {r}"); let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap(); @@ -463,11 +465,59 @@ mod tests { let c = pm.data()[y * pm.width() as usize + x]; c.r as u32 + 255 - c.a as u32 }; - let (l, m, r) = (at(105, 150), at(200, 150), at(295, 150)); + let (l, m, r) = (at(125, 180), at(220, 180), at(315, 180)); assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right in PDF: {l} {m} {r}"); } } + #[test] + fn group_gradient_feather_keeps_distant_shadows_and_thick_strokes() { + for shadow in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let mut ids = vec![]; + for rect in [[120, 120, 170, 170], [175, 120, 225, 170]] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + if shadow { + s.execute("object.dropShadow", &json!({"ids": [id], "on": true, "distance": 180, "angle": 90, "size": 0, "opacity": 1})).unwrap(); + } else { + s.execute("object.stroke", &json!({"ids": [id], "swatch": "[Black]", "weight": 180})).unwrap(); + } + ids.push(id); + } + let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap(); + // Translate the group so mask geometry and gradient paint exercise distinct spaces. + s.execute("transform.move", &json!({"ids": [g], "dx": 30, "dy": 20})).unwrap(); + let point = if shadow { (175, 345) } else { (175, 60) }; + let sample = |s: &Session| { + let d = &s.doc().unwrap().doc; + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + // Zoomed viewport: the mask must extend over every device pixel. + let img = rr.render_page(d, &s.cache, 0, 2.0, false, &Default::default()).unwrap(); + let screen = img.pixel(point.0 * 2, point.1 * 2)[0]; + let pdf = designcraft_pdf::export_pdf(d, &s.cache, &Default::default()).unwrap(); + let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap(); + let c = pm.data()[point.1 as usize * pm.width() as usize + point.0 as usize]; + [f32::from(screen), c.r as f32 + 255.0 - c.a as f32] + }; + let before = sample(&s); + // The sample lies past either end of the gradient, and outside the old fixed padding. + s.execute("object.gradientFeather", &json!({"ids": [g], "from": [130, 120], "to": [130, 220], "start": 40, "end": 80})).unwrap(); + let after = sample(&s); + let opacity = if shadow { 0.8 } else { 0.4 }; + for (kind, (before, after)) in ["screen", "PDF"].into_iter().zip(before.into_iter().zip(after)) { + assert!(before < 120.0, "{kind}, shadow={shadow}: sample contains the effect before feathering: {before}"); + let expected = 255.0 - (255.0 - before) * opacity; + assert!( + (after - expected).abs() < 16.0, + "{kind}, shadow={shadow}: end opacity continues past geometry: {after}, expected {expected}" + ); + } + } + } + #[test] fn create_outlines_turns_text_into_paths() { let mut s = Session::new(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 3627fd954..983be9433 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -772,7 +772,7 @@ impl Exporter<'_> { cx: p0.x as f32, cy: p0.y as f32, cr: r, - transform: Transform::identity(), + transform: tf(xf), spread_method: SpreadMethod::Pad, stops, anti_alias: false, @@ -784,30 +784,21 @@ impl Exporter<'_> { y1: p0.y as f32, x2: p1.x as f32, y2: p1.y as f32, - transform: Transform::identity(), + transform: tf(xf), spread_method: SpreadMethod::Pad, stops, anti_alias: false, } .into() }; - // A group's mask covers its objects (it has no path of its own), with room for text and - // strokes past their frames. - let area = if it.is_group() { - let b = it.feather_bounds(); - let pad = 36.0 + 0.25 * b.width().max(b.height()); - b.inflate(pad, pad) - } else { - it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0) - }; + // The gradient keeps its item-space geometry, but its mask covers the complete exported + // area (including bleed), so distant child shadows and thick strokes are never cut off. let mut sb = s.stream_builder(); let mut ms = sb.surface(); - ms.push_transform(&tf(xf)); - if let Some(p) = to_path(&area.to_path(0.1)) { + if let Some(p) = to_path(&self.clip.to_path(0.1)) { ms.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); ms.draw_path(&p); } - ms.pop(); ms.finish(); let stream = sb.finish(); s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity)); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0e1ffd565..dab2e3ad0 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -64,9 +64,10 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter { }) } -/// Keep what was drawn in the current layer as much as `it`'s gradient feather says, over `area` -/// (item space; past the gradient's ends its end opacities carry on). -pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine, area: Rect) { +/// Keep what was drawn in the current layer as much as `it`'s gradient feather says. Cover the +/// whole viewport: strokes, text and child effects can extend arbitrarily past the geometry, +/// and past the gradient's ends its end opacities carry on. +pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine) { let gf = &it.effects.gradient_feather; let (p0, p1) = gf.points(it.feather_bounds()); let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8))); @@ -78,9 +79,9 @@ pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Ite }; ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); - ctx.set_transform(f.view * xf); ctx.set_paint(grad); - ctx.fill_rect(&area); + ctx.set_paint_transform(f.view * xf); + ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()))); ctx.reset_paint_transform(); ctx.pop_layer(); } @@ -191,8 +192,7 @@ impl Renderer { } } if gf.on { - let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { it.inner_bounds() }; - gradient_feather_mask(ctx, f, it, xf, area.inflate(4.0, 4.0)); + gradient_feather_mask(ctx, f, it, xf); } if gf.on || directional { ctx.pop_layer(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ea942c2c8..cc937a1a2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -502,10 +502,7 @@ impl Renderer { } } if feather { - // Generous: text and strokes reach past their frames. - let b = it.feather_bounds(); - let pad = 36.0 + 0.25 * b.width().max(b.height()); - fx::gradient_feather_mask(ctx, f, it, xf, b.inflate(pad, pad)); + fx::gradient_feather_mask(ctx, f, it, xf); ctx.pop_layer(); } if layered { From 6f47f10b4b24a83b5e29d32eb42c7179c2a6ac50 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 10:33:52 +0200 Subject: [PATCH 016/128] fix(render): Blend modes don't blend with the paper The renderer painted the paper, then blended page items with it: black text in Color Dodge over blank paper dodged the white to white and vanished, and other modes showed the same way. In InDesign (and in PDF, where the page group starts transparent) the paper is not a backdrop: over blank paper an object in any blend mode shows as it is, and only the objects below it are blended with. On spreads that use a blend mode the items are now drawn in their own group, laid over the paper once; other spreads render as before. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/render/src/lib.rs | 21 +++++++++++ crates/render/tests/blend_over_paper.rs | 48 +++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 crates/render/tests/blend_over_paper.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..59b58a090 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -153,6 +153,7 @@ Newest first. - **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change. - **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change. - **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. +- **2026-10-12** · On screen, blend modes blend with the objects below and not with the paper, as in InDesign and in PDF export: a Color Dodge or Screen object over blank paper shows as it is. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..a381b1698 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -394,6 +394,13 @@ impl Renderer { } } } + // Blend modes blend with the objects below, not with the paper (as in InDesign and in PDF, + // where the page's group starts transparent): a Color Dodge or Screen object over blank paper + // shows as it is. So the items are drawn in their own group, laid over the paper once. + let isolate = f.opts.paper && spread_blends(doc, sp); + if isolate { + ctx.push_layer(None, None, None, None, None); + } // Layers back to front; within each layer parent items first (document spreads only). for layer in doc.layers.iter().rev() { if !layer.visible || f.opts.printing_only && !layer.printable { @@ -428,6 +435,9 @@ impl Renderer { } } } + if isolate { + ctx.pop_layer(); + } } fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: Option) { @@ -920,6 +930,17 @@ pub(crate) fn overprints(f: &Frame, swatch: &str, tint: f32, flag: bool) -> bool f.opts.overprint_preview && (flag || (f.doc.settings.overprint_black && swatch == designcraft_color::swatch::BLACK && tint >= 0.999)) } +/// Does any item on the spread, or on the parent pages it shows, use a blend mode other than +/// Normal (groups and pasted-in items included)? +fn spread_blends(doc: &Document, sp: &designcraft_doc::Spread) -> bool { + let blends = |it: &Item| { + let mut any = false; + it.walk(&mut |i| any |= i.blend != Default::default()); + any + }; + sp.items.iter().any(|i| blends(i)) || doc.parents.iter().any(|p| p.items.iter().any(|i| blends(i))) +} + pub fn blend_mode(b: DcBlend) -> BlendMode { use DcBlend as B; let mix = match b { diff --git a/crates/render/tests/blend_over_paper.rs b/crates/render/tests/blend_over_paper.rs new file mode 100644 index 000000000..0fd257027 --- /dev/null +++ b/crates/render/tests/blend_over_paper.rs @@ -0,0 +1,48 @@ +//! Blend modes blend with the objects below, never with the paper: over blank paper an object in +//! Color Dodge (or any other mode) shows as it is, as in InDesign and in the exported PDF. + +use designcraft_color::{BlendMode, Color, Swatch}; +use designcraft_compose::Cache; +use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, build::NewDocument}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{Placed, RenderOptions, Renderer}; + +/// A 200 x 100 page: a mid-blue rectangle on the right half, and over both halves a black +/// rectangle in `blend`. +fn document(blend: BlendMode) -> Document { + let mut d = Document::new(&NewDocument { width: 200.0, height: 100.0, facing_pages: false, ..Default::default() }); + d.swatches.push(Swatch::color("Mid blue", Color::rgb(0.2, 0.4, 0.6))); + let mut rect = |r: Rect, swatch: &str, blend: BlendMode| { + let id = ItemId(d.alloc()); + let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(r)); + it.fill = Fill::swatch(swatch); + it.blend = blend; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + }; + rect(Rect::new(100.0, 0.0, 200.0, 100.0), "Mid blue", BlendMode::Normal); + rect(Rect::new(20.0, 20.0, 180.0, 80.0), "[Black]", blend); + d +} + +fn pixel(d: &Document, x: u32, y: u32) -> [u8; 4] { + let mut r = Renderer::new(); + r.threads = 0; + let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let img = r.render(d, &Cache::new(), &placed, 200, 100, Affine::IDENTITY, &RenderOptions::default()); + let i = ((y * 200 + x) * 4) as usize; + [img.pixels[i], img.pixels[i + 1], img.pixels[i + 2], img.pixels[i + 3]] +} + +#[test] +fn a_blend_mode_over_blank_paper_shows_the_object_as_it_is() { + let d = document(BlendMode::ColorDodge); + // Over the paper: black ([Black] shows as about 52 grey), as in Normal (it used to dodge the white paper to white, vanishing). + let over_paper = pixel(&d, 50, 50); + assert!(over_paper[0] < 80 && over_paper[1] < 80 && over_paper[2] < 80, "{over_paper:?}"); + // Over the blue rectangle it still blends: black in Color Dodge leaves the blue as it is. + let over_blue = pixel(&d, 150, 50); + assert!(over_blue[2] > 120 && over_blue[2] > over_blue[0], "{over_blue:?}"); + // Normal is unchanged. + let normal = pixel(&document(BlendMode::Normal), 150, 50); + assert!(normal[0] < 80 && normal[2] < 80, "{normal:?}"); +} From 722ebf4829a515bb72748259fa34bad43d4bd8df Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 11:39:41 +0200 Subject: [PATCH 017/128] Repaint a moved text frame's whole text box Text is composed in the box around a frame's path in the frame's own coordinates. A sheared then rotated frame turns that box past its path's bounds, which was all a move repainted, so its text left trails behind. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/render/src/damage.rs | 75 +++++++++++++++++++++++++++---------- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..4f8a16cb8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -153,6 +153,7 @@ Newest first. - **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change. - **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change. - **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. +- **2026-10-11** · Moving a sheared and rotated text frame no longer leaves trails of text behind: canvas repaints cover the frame's whole text box, not just its outline. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/render/src/damage.rs b/crates/render/src/damage.rs index 31f9e030b..add8d011c 100644 --- a/crates/render/src/damage.rs +++ b/crates/render/src/damage.rs @@ -156,14 +156,27 @@ fn item_area(it: &Item) -> Option { return None; } let pad = it.stroke.extent().max(0.0) + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 }; - Some(it.bounds().inflate(pad, pad)) + Some(painted_bounds(it).inflate(pad, pad)) +} + +/// An item's bounds, grown to its text: text is composed in the box around the frame's path in +/// the frame's own coordinates, which a sheared then rotated frame turns past its path's bounds. +fn painted_bounds(it: &Item) -> Rect { + let mut b = it.bounds(); + if it.is_text_frame() { + b = b.union(it.xf.transform_rect_bbox(it.inner_bounds())); + } + for c in it.children() { + b = b.union(it.xf.transform_rect_bbox(painted_bounds(c))); + } + b } /// A text frame's area: its spread and bounds with room for glyph overhang. fn frame_area(d: &Document, f: ItemId) -> Option<(SpreadRef, Rect)> { let loc = d.find(f)?; let it = d.item_at(&loc)?; - let b = d.parent_xf(&loc).transform_rect_bbox(it.bounds()); + let b = d.parent_xf(&loc).transform_rect_bbox(painted_bounds(it)); // Overhang of italics, swashes and big initials, and anchored objects pinned to lines. Some((loc.spread, b.inflate(24.0 + 0.1 * b.width().max(b.height()), 24.0))) } @@ -207,32 +220,26 @@ mod tests { assert!(dmg.iter().any(|(_, r)| r.contains(designcraft_geom::Point::new(350.0, 100.0)))); } - #[test] - fn patched_render_equals_full_render() { + /// Channels where `old`'s render patched over `new`'s damage differs from `new`'s full render. + fn patched_diff(old: &Document, new: &Document, (w, h): (u32, u32), zoom: f64) -> usize { use crate::{Placed, RenderOptions, Renderer}; use designcraft_geom::{Affine, Vec2}; - let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); - let lid = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 300.0, 200.0), lid, "Hello there", ParaFormat::default()).unwrap(); let cache = designcraft_compose::Cache::new(); let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::translate(Vec2::ZERO) }]; - let (w, h) = (400u32, 300u32); - let view = Affine::scale(1.3); + let view = Affine::scale(zoom); let mut r = Renderer::new(); r.threads = 0; let opts = RenderOptions::default(); - let old = d.clone(); - let mut img = r.render(&old, &cache, &placed, w, h, view, &opts); - d.story_mut(sid).unwrap().insert(5, ", typed"); - let full = r.render(&d, &cache, &placed, w, h, view, &opts); + let mut img = r.render(old, &cache, &placed, w, h, view, &opts); + let full = r.render(new, &cache, &placed, w, h, view, &opts); // Patch the damaged pixels. - let regions = damage(&old, &d).unwrap(); + let regions = damage(old, new).unwrap(); let u = regions.iter().map(|(_, r)| *r).reduce(|a, b| a.union(b)).unwrap(); - let x0 = ((u.x0 * 1.3).floor().max(0.0)) as u32; - let y0 = ((u.y0 * 1.3).floor().max(0.0)) as u32; - let x1 = ((u.x1 * 1.3).ceil() as u32).min(w); - let y1 = ((u.y1 * 1.3).ceil() as u32).min(h); - let patch = r.render(&d, &cache, &placed, x1 - x0, y1 - y0, Affine::translate((-(x0 as f64), -(y0 as f64))) * view, &opts); + let x0 = ((u.x0 * zoom).floor().max(0.0)) as u32; + let y0 = ((u.y0 * zoom).floor().max(0.0)) as u32; + let x1 = ((u.x1 * zoom).ceil() as u32).min(w); + let y1 = ((u.y1 * zoom).ceil() as u32).min(h); + let patch = r.render(new, &cache, &placed, x1 - x0, y1 - y0, Affine::translate((-(x0 as f64), -(y0 as f64))) * view, &opts); for y in y0..y1 { for x in x0..x1 { let pi = (((y - y0) * (x1 - x0) + (x - x0)) * 4) as usize; @@ -240,10 +247,38 @@ mod tests { img.pixels[ii..ii + 4].copy_from_slice(&patch.pixels[pi..pi + 4]); } } - let diff = img.pixels.iter().zip(&full.pixels).filter(|(a, b)| a.abs_diff(**b) > 2).count(); + img.pixels.iter().zip(&full.pixels).filter(|(a, b)| a.abs_diff(**b) > 2).count() + } + + #[test] + fn patched_render_equals_full_render() { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 300.0, 200.0), lid, "Hello there", ParaFormat::default()).unwrap(); + let old = d.clone(); + d.story_mut(sid).unwrap().insert(5, ", typed"); + let diff = patched_diff(&old, &d, (400, 300), 1.3); assert_eq!(diff, 0, "patched image differs from the full render in {diff} channels"); } + #[test] + fn moving_a_sheared_turned_text_frame_leaves_no_trail() { + use designcraft_geom::Affine; + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let text = "Lorem ipsum dolor sit amet. ".repeat(60); + let (f, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 200.0), lid, &text, ParaFormat::default()).unwrap(); + // Sheared outline (its box's corners are outside it, the text's first and last lines in + // them), then turned: the text reaches past the outline's bounds. + let it = d.item_mut(f).unwrap(); + it.path = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 200.0)).transformed(Affine::skew(0.5, 0.0)); + it.xf = Affine::translate((150.0, 60.0)) * Affine::rotate(30f64.to_radians()); + let old = d.clone(); + d.item_mut(f).unwrap().xf = Affine::translate((210.0, 90.0)) * Affine::rotate(30f64.to_radians()); + let diff = patched_diff(&old, &d, (700, 560), 1.0); + assert_eq!(diff, 0, "the moved text left {diff} stale channels behind"); + } + #[test] fn only_changed_frames_of_a_thread_are_damaged() { let mut d = Document::new(&NewDocument { pages: 3, facing_pages: false, ..Default::default() }); From e1936cbfd0c181eebf2fefef4a4e49bb277833b0 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:18:48 +0200 Subject: [PATCH 018/128] fix(ui): Highlight swatch list rows and keep panels clear of scroll bars Swatch lists (Character panel, Properties, task bar) shaded the row under the pointer with the theme's hover grey, a few levels from the popup's own background, so it looked as if nothing changed. The row under the pointer is now white on blue, like a menu; the current swatch keeps its shading. The lists also grow to fit their longest name, and their rows stop short of the scroll bar that floats over the list's edge, so names like C=15 M=100 Y=100 K=0 are no longer cut by it. The four copies of the list are one shared function now. The Glyphs panel counted its columns from the panel's whole width, so the last column ran under the floating scroll bar and was cut off; the columns now leave the bar room. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/glyphs.rs | 49 +++++++- crates/ui-egui/src/panels/mod.rs | 155 +++++++++++++++++++++--- crates/ui-egui/src/panels/properties.rs | 11 +- crates/ui-egui/src/taskbar.rs | 22 +--- 4 files changed, 186 insertions(+), 51 deletions(-) diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 3fb9d00eb..63f02e9ac 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -71,7 +71,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } }); } - let cols = ((ui.available_width() / CELL).floor() as u32).max(1); + // The scroll bar floats over the grid's right edge: leave it room. + let scroll = &ui.spacing().scroll; + let bar = scroll.bar_inner_margin + scroll.bar_width + scroll.bar_outer_margin; + let cols = (((ui.available_width() - bar) / CELL).floor() as u32).max(1); let ppp = ui.ctx().pixels_per_point(); let cell_px = (CELL * ppp).round() as u32; let ink = t.text_strong; @@ -138,3 +141,47 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ); ui.data_mut(|d| d.insert_temp(id, st)); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_grid_leaves_room_for_the_scroll_bar() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, ""); + crate::theme::apply(&ctx, &Tokens::for_brightness(crate::theme::Brightness::MediumDark)); + let scroll = ctx.global_style().spacing.scroll; + let bar = scroll.bar_inner_margin + scroll.bar_width + scroll.bar_outer_margin; + for width in [236.0, 256.0, 300.0, 333.0] { + let mut panel = egui::Rect::NOTHING; + let mut grid = None; + for _ in 0..2 { + // The app raises the texture limit for the grid (see `test_window`). + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 700.0))), + max_texture_side: Some(8192), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + ui.allocate_ui(vec2(width, 650.0), |ui| { + ui.set_width(width); + panel = ui.max_rect(); + show(&mut app, ui); + }); + }); + out.textures_delta.clear(); + // The grid is the one textured mesh that isn't text. + grid = out.shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => Some(m.calc_bounds()), + _ => None, + }); + } + let grid = grid.expect("the glyph grid is drawn"); + assert!(grid.right() <= panel.right() - bar + 0.5, "{width}: the grid {grid:?} runs under the scroll bar of {panel:?}"); + assert!(grid.right() > panel.right() - bar - CELL, "{width}: no column is left out ({grid:?})"); + } + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 581f6747e..9100ffe9b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -158,29 +158,67 @@ const SWATCH_MENU_ROW_H: f32 = 22.0; /// One swatch-menu row: a chip and the swatch name; the whole row is clickable (the chip included). /// `name == current` highlights it. Returns true when the row was clicked. -pub fn swatch_menu_row(ui: &mut egui::Ui, doc: &designcraft_doc::Document, name: &str, current: &str) -> bool { +/// Where a swatch row's name starts, after its chip. +const SWATCH_MENU_TEXT_X: f32 = 28.0; + +fn swatch_menu_font() -> egui::FontId { + egui::FontId::proportional(12.5) +} + +/// One swatch in a swatch list, `width` wide: the swatch under the pointer is highlighted (white +/// on blue, like a menu), the current one shaded. +pub fn swatch_menu_row(ui: &mut egui::Ui, doc: &designcraft_doc::Document, name: &str, current: &str, width: f32) -> bool { let t = Tokens::get(ui.ctx()); - let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), SWATCH_MENU_ROW_H), egui::Sense::click()); + let (row, resp) = ui.allocate_exact_size(vec2(width, SWATCH_MENU_ROW_H), egui::Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, name == current, name)); - if name == current { - ui.painter().rect_filled(row, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(row, 0.0, t.hover); + let hovered = resp.hovered(); + if hovered { + ui.painter().rect_filled(row, 2.0, t.accent_strong); + } else if name == current { + ui.painter().rect_filled(row, 2.0, t.row_selected); } let (c, g) = crate::widgets::swatch_colors(doc, name, 1.0); let chip = egui::Rect::from_min_size(row.min + vec2(4.0, 3.0), vec2(16.0, 16.0)); crate::widgets::paint_chip(ui.painter(), chip, c, g); + // A name too long for the list is cut at the row's end. + let text = ui.painter().with_clip_rect(ui.clip_rect().intersect(row.shrink2(vec2(2.0, 0.0)))); crate::rtl::paint( - ui.painter(), - row.min + vec2(28.0, SWATCH_MENU_ROW_H / 2.0), + &text, + row.min + vec2(SWATCH_MENU_TEXT_X, SWATCH_MENU_ROW_H / 2.0), egui::Align2::LEFT_CENTER, name, - egui::FontId::proportional(12.5), - t.text, + swatch_menu_font(), + if hovered { egui::Color32::WHITE } else { t.text }, ); resp.clicked() } +/// The list in a swatch popup → the swatch picked. It is as wide as its longest name (within +/// limits), and its rows stop short of the scroll bar that floats over the list's edge. +pub fn swatch_list(ui: &mut egui::Ui, doc: &designcraft_doc::Document, current: &str) -> Option { + let font = swatch_menu_font(); + let widest = doc + .swatches + .iter() + .map(|sw| ui.painter().layout_no_wrap(sw.name.clone(), font.clone(), egui::Color32::PLACEHOLDER).size().x) + .fold(0.0, f32::max); + let scroll = &ui.spacing().scroll; + let bar = scroll.bar_inner_margin + scroll.bar_width + scroll.bar_outer_margin; + let width = (SWATCH_MENU_TEXT_X + widest + 10.0 + bar).clamp(200.0, 360.0); + ui.set_min_width(width); + ui.set_max_width(width); + let mut picked = None; + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for sw in &doc.swatches { + if swatch_menu_row(ui, doc, &sw.name, current, width - bar) { + picked = Some(sw.name.clone()); + ui.close(); + } + } + }); + picked +} + /// A swatch dropdown showing a chip and name; `on_pick` gets the chosen swatch name. pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current: Option, on_pick: impl FnOnce(&mut DesignApp, String)) { let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; @@ -202,16 +240,7 @@ pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current: Tokens::get(ui.ctx()).icon, ); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(200.0); - ui.set_max_width(280.0); - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for sw in &doc.swatches { - if swatch_menu_row(ui, &doc, &sw.name, &cur) { - picked = Some(sw.name.clone()); - ui.close(); - } - } - }); + picked = swatch_list(ui, &doc, &cur); }); }); if let Some(p) = picked { @@ -698,3 +727,89 @@ mod font_menu_ui_tests { } } } + +#[cfg(test)] +mod swatch_list_tests { + use super::*; + + fn rects(shapes: &[egui::epaint::ClippedShape]) -> Vec<(egui::Rect, egui::Color32)> { + fn walk(s: &egui::Shape, out: &mut Vec<(egui::Rect, egui::Color32)>) { + match s { + egui::Shape::Rect(r) => out.push((r.rect, r.fill)), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = vec![]; + shapes.iter().for_each(|s| walk(&s.shape, &mut out)); + out + } + + fn texts(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, egui::Rect)> { + fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.job.text.clone(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = vec![]; + shapes.iter().for_each(|s| walk(&s.shape, &mut out)); + out + } + + #[test] + fn the_swatch_under_the_pointer_is_highlighted_and_names_clear_the_scroll_bar() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({})).unwrap(); + // A long name, as process swatches have. + app.run("swatch.create", serde_json::json!({"name": "C=15 M=100 Y=100 K=0 Long Name", "color": {"c": 15, "m": 100, "y": 100, "k": 0}})) + .unwrap(); + let long = "C=15 M=100 Y=100 K=0 Long Name"; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, ""); + let tokens = Tokens::for_brightness(crate::theme::Brightness::MediumDark); + crate::theme::apply(&ctx, &tokens); + let mut time = 0.0; + let mut frame = |app: &mut DesignApp, events: Vec| { + time += 0.1; + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 700.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| swatch_picker(app, ui, "list", Some("[Black]".into()), |_, _| {})); + out.textures_delta.clear(); + out.shapes + }; + frame(&mut app, vec![]); + let chip = egui::pos2(17.0, 17.0); + for pressed in [true, false] { + frame( + &mut app, + vec![ + egui::Event::PointerMoved(chip), + egui::Event::PointerButton { pos: chip, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ); + } + let shapes = frame(&mut app, vec![]); + let (_, name) = texts(&shapes).into_iter().find(|(t, _)| t == "[Registration]").expect("the list is open"); + let pointer = name.center(); + frame(&mut app, vec![egui::Event::PointerMoved(pointer)]); + let shapes = frame(&mut app, vec![]); + let all = rects(&shapes); + let (row, _) = *all.iter().find(|(r, c)| *c == tokens.accent_strong && r.contains(pointer)).expect("the hovered row is highlighted"); + let (list, _) = *all + .iter() + .filter(|(r, c)| *c == tokens.panel && r.contains_rect(row)) + .min_by(|a, b| a.0.area().total_cmp(&b.0.area())) + .expect("the popup"); + let scroll = ctx.global_style().spacing.scroll; + assert!(row.right() + scroll.bar_width <= list.right(), "rows {row:?} stop short of the scroll bar in {list:?}"); + let (_, long_rect) = texts(&shapes).into_iter().find(|(t, _)| t == long).unwrap(); + assert!(long_rect.right() <= row.right(), "the longest name {long_rect:?} fits its row {row:?}"); + assert!(texts(&shapes).iter().any(|(t, r)| t == "[Black]" && r.right() <= row.right())); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..03c2c87a6 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -725,16 +725,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { fn swatch_menu(app: &mut DesignApp, resp: &egui::Response, doc: &designcraft_doc::Document, cur: &str, on_pick: impl FnOnce(&mut DesignApp, String)) { let mut picked = None; egui::Popup::menu(resp).show(|ui| { - ui.set_min_width(200.0); - ui.set_max_width(280.0); - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for sw in &doc.swatches { - if super::swatch_menu_row(ui, doc, &sw.name, cur) { - picked = Some(sw.name.clone()); - ui.close(); - } - } - }); + picked = super::swatch_list(ui, doc, cur); }); if let Some(p) = picked { on_pick(app, p); diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index cf66e9d5f..eaf5e7cce 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -168,16 +168,7 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) { resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Task Bar fill")); let mut picked = None; egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(180.0); - ui.set_max_width(280.0); - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for s in &doc.swatches { - if panels::swatch_menu_row(ui, &doc, &s.name, &sw) { - picked = Some(s.name.clone()); - ui.close(); - } - } - }); + picked = panels::swatch_list(ui, &doc, &sw); }); if let Some(p) = picked { let _ = app.run("type.char", json!({"attrs": {"fill": p}})); @@ -241,16 +232,7 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) { }); let mut picked = None; egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(180.0); - ui.set_max_width(280.0); - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for s in &doc.swatches { - if panels::swatch_menu_row(ui, &doc, &s.name, &sw) { - picked = Some(s.name.clone()); - ui.close(); - } - } - }); + picked = panels::swatch_list(ui, &doc, &sw); }); if let Some(p) = picked { let _ = app.run(cmd, json!({"swatch": p})); From 7192c2e13684e985f8fb2787d3bffe8b4f859976 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 23:48:11 +0200 Subject: [PATCH 019/128] feat(eyedropper): A sampled colour goes to the stroke when the Stroke box is in front As in InDesign, the Eyedropper applies a colour sampled from a placed image to the fill or the stroke, whichever is in front in the toolbox: with the Stroke box in front a selected line takes it on its stroke (it has no fill to show it). The toolbox's state reaches tools through ViewInfo/ToolContext::stroke_in_front. Depends on #572. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 11 +++++++++++ crates/engine/src/tooling.rs | 5 ++++- crates/tools/src/lib.rs | 2 ++ crates/tools/src/snap.rs | 12 +++++++++++- crates/tools/src/tests.rs | 23 ++++++++++++++++++++++- crates/tools/src/xform.rs | 7 ++++++- crates/ui-egui/src/lib.rs | 7 ++++++- 8 files changed, 63 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..ad1576ab3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,6 +138,7 @@ Newest first. - **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change. - **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change. - **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change. +- **2026-10-12** · The Eyedropper puts a colour sampled from an image on the stroke when the toolbox's Stroke box is in front, as in InDesign (a line takes it on its stroke). No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f099..9f0321560 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1750,4 +1750,15 @@ mod sample_color_tests { assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); } } + + #[test] + fn sampling_with_the_stroke_in_front_colours_a_lines_stroke() { + let (mut s, _) = session_with_image(); + let line = s.execute("line.create", &json!({"a": [300, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [line]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + let it = s.doc().unwrap().doc.item(ItemId(line)).unwrap().clone(); + assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap(), "{r}"); + assert!(it.fill.is_none(), "the fill stays empty"); + } } diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1bf8db930..28ed3dd33 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -35,11 +35,13 @@ pub struct ViewInfo { pub zoom: f64, pub snap: SnapView, pub unit: Unit, + /// The toolbox's Stroke box is in front (X): colour tools apply to strokes. + pub stroke_in_front: bool, } impl ViewInfo { pub const fn at_zoom(zoom: f64) -> Self { - Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points } + Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false } } } @@ -93,6 +95,7 @@ impl Session { layer, snap: view.snap, unit: view.unit, + stroke_in_front: view.stroke_in_front, }; Some(f(self.tool.as_mut(), &cx)) } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f3de390a4..d8a5c5446 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -205,6 +205,8 @@ pub struct ToolContext<'a> { pub layer: designcraft_doc::LayerId, pub snap: SnapView, pub unit: Unit, + /// The toolbox's Stroke box is in front: sampled colours go to strokes (the Eyedropper). + pub stroke_in_front: bool, } impl ToolContext<'_> { diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 6d5c8f6a5..a7b4dad3b 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -1473,7 +1473,17 @@ mod tests { } fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: &'a CanvasLayout) -> ToolContext<'a> { - ToolContext { doc, selection: sel, cache, layout, zoom: 1.0, layer: doc.default_layer(), snap: SnapView::FACTORY, unit: Unit::Points } + ToolContext { + doc, + selection: sel, + cache, + layout, + zoom: 1.0, + layer: doc.default_layer(), + snap: SnapView::FACTORY, + unit: Unit::Points, + stroke_in_front: false, + } } fn y_req(rect: Rect) -> SnapRequest<'static> { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6776902aa..f058e5e03 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -9,7 +9,17 @@ use designcraft_geom::{Point, Rect, Unit}; use super::*; fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) -> ToolContext<'a> { - ToolContext { doc: d, selection: s, cache: c, layout: l, zoom: 1.0, layer: d.default_layer(), snap: SnapView::OFF, unit: Unit::Points } + ToolContext { + doc: d, + selection: s, + cache: c, + layout: l, + zoom: 1.0, + layer: d.default_layer(), + snap: SnapView::OFF, + unit: Unit::Points, + stroke_in_front: false, + } } #[test] @@ -857,6 +867,17 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { assert_eq!(a, vec![sample(120.0, 130.0)]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 180.0 + off.x, 180.0 + off.y)); assert_eq!(a, vec![sample(180.0, 180.0)], "through the text frame"); + // With the toolbox's Stroke box in front, the colour goes to the stroke (a line has no fill). + let mut cx_stroke = ctx(&d, &s, &c, &l); + cx_stroke.stroke_in_front = true; + let a = t.pointer(&cx_stroke, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!( + a, + vec![Action::Exec( + "color.sample".into(), + serde_json::json!({"at": [120.0, 130.0], "spread": {"kind": "doc", "index": 0}, "target": "stroke"}) + )] + ); // A filled object is matched as before (here on a locked layer: not reachable, so nothing). assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)).is_empty()); let mut d2 = d.clone(); diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index e0bcd24f3..194ceff05 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -299,7 +299,12 @@ impl Tool for EyedropperTool { let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some(); // A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing). if selected && let Some((sr, at)) = image_under(cx, ev.pos) { - return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))]; + // To the fill or stroke in front in the toolbox, as in InDesign. + let mut p = json!({"at": [at.x, at.y], "spread": sr}); + if cx.stroke_in_front { + p["target"] = json!("stroke"); + } + return vec![Action::Exec("color.sample".into(), p)]; } let Some((_, id)) = cx.hit(ev.pos) else { return vec![] }; if !selected { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..4dd58ebe4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -598,7 +598,12 @@ impl DesignApp { self.pane = pane; } pub fn view_info(&self) -> ViewInfo { - ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), snap: self.ui.snap_view(), unit: self.ui.units } + ViewInfo { + zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), + snap: self.ui.snap_view(), + unit: self.ui.units, + stroke_in_front: self.ui.stroke_front, + } } pub fn status(&mut self, s: impl Into) { From fc31170346bfe2cac9149fb986a435f9bdf24d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20Kr=C3=BCger?= Date: Tue, 13 Oct 2026 13:00:32 +0200 Subject: [PATCH 020/128] fix(pptx): refuse to embed fonts whose licence forbids outline embedding eot() only refused fsType 0x0002 exactly, so 0x0003 (which FontFace::restricted_licence and file.package already treat as restricted) and Bitmap embedding only (bit 9, e.g. 0x0200, 0x0208) were embedded with their glyf outlines. Reuse restricted_licence and also refuse bit 9, matching VectorCraft's Package (storytold/vectorcraft#782). --- Cargo.lock | 2 +- crates/pptx/Cargo.toml | 1 + crates/pptx/src/fonts.rs | 7 ++++--- crates/pptx/src/tests.rs | 18 ++++++++++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82c9f7cc8..c2af98737 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1082,7 +1082,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-compose", diff --git a/crates/pptx/Cargo.toml b/crates/pptx/Cargo.toml index fc8fbac71..c6e1c876f 100644 --- a/crates/pptx/Cargo.toml +++ b/crates/pptx/Cargo.toml @@ -21,4 +21,5 @@ thiserror = { workspace = true } zip = { workspace = true } [dev-dependencies] +designcraft-fonts = { workspace = true, features = ["testing"] } quick-xml = { workspace = true } diff --git a/crates/pptx/src/fonts.rs b/crates/pptx/src/fonts.rs index 4dceb6ea6..3d5eb4c9b 100644 --- a/crates/pptx/src/fonts.rs +++ b/crates/pptx/src/fonts.rs @@ -158,7 +158,7 @@ fn utf16_name(out: &mut Vec, s: &str) { /// Why a face can't be embedded. #[derive(Debug, PartialEq, Eq)] pub enum NoEmbed { - /// The font's licence (OS/2 `fsType`) forbids embedding. + /// The font's licence (OS/2 `fsType`) forbids embedding it, or embedding its outlines. Restricted, /// PowerPoint embeds TrueType outlines only (not CFF, collections or variable instances). Format, @@ -180,8 +180,9 @@ pub fn eot(face: &FontFace) -> Result, NoEmbed> { let os2 = f.table_data(Tag::new(b"OS/2")).map(|t| t.as_bytes().to_vec()).unwrap_or_default(); let head = f.table_data(Tag::new(b"head")).map(|t| t.as_bytes().to_vec()).unwrap_or_default(); let fs_type = be16(&os2, 8).unwrap_or(0); - // Restricted License embedding (bit 1 alone): may not be embedded. - if fs_type & 0x000F == 0x0002 { + // Restricted License embedding, or Bitmap embedding only (bit 9): PowerPoint embeds the + // outlines, so neither may be embedded. + if face.restricted_licence() || fs_type & 0x0200 != 0 { return Err(NoEmbed::Restricted); } let mut h: Vec = Vec::with_capacity(256); diff --git a/crates/pptx/src/tests.rs b/crates/pptx/src/tests.rs index 0ee9a61ce..98e78384e 100644 --- a/crates/pptx/src/tests.rs +++ b/crates/pptx/src/tests.rs @@ -269,3 +269,21 @@ fn fonts_embed_as_embedded_opentype() { let ct = part(&mut z, "[Content_Types].xml"); assert!(ct.contains("application/x-fontdata")); } + +#[test] +fn fonts_whose_licence_forbids_outline_embedding_are_not_embedded() { + use designcraft_fonts::testing::{font_with, with_fs_type}; + let db = designcraft_fonts::FontDb::with_font_dirs(vec![]); + // Restricted (0x0002, 0x0003 with reserved bit 0) and Bitmap embedding only (bit 9, with any + // other bits) refuse; Installable, Preview & Print and Editable embed. + for (fs_type, embeds) in [(0x0000, true), (0x0004, true), (0x0008, true), (0x0002, false), (0x0003, false), (0x0200, false), (0x0208, false)] { + let family = format!("PPTX Licence {fs_type:04x}"); + assert_eq!(db.add_font(with_fs_type(font_with(&family, &['l']).unwrap(), fs_type).unwrap()), 1); + let r = crate::fonts::eot(&db.face(&family, "Regular")); + if embeds { + assert!(r.is_ok(), "fsType {fs_type:#06x}"); + } else { + assert!(matches!(r, Err(crate::fonts::NoEmbed::Restricted)), "fsType {fs_type:#06x}"); + } + } +} From a59919bd2456ea638b9e552220bcad6829d1e63f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 14:26:00 +0200 Subject: [PATCH 021/128] feat(ui): Text Wrap panel with four offsets, link and Invert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign's Text Wrap panel has the five wrap buttons with Invert, then Top, Left, Bottom and Right offsets around a link button that makes them all the same. DesignCraft's panel had a single Offset field that always showed 0 (the object's offset was never read), and Invert only in Properties. - SelInfo carries the wrap offsets; the panel shows each side's value in the document's units and sets one side, or all four while linked (linked by default when they're equal). - Invert sits next to the wrap buttons; fields are disabled without a wrap. LYH Studio episode 14 (11:13) opens Window › Text Wrap to give the quote a Jump Object wrap. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/panels/mod.rs | 3 + crates/ui-egui/src/panels/properties.rs | 109 ++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..0a1a141e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · The Text Wrap panel is laid out as InDesign's: the wrap buttons with Invert, and the four offsets (Top, Left, Bottom, Right) showing the object's values, with the link button that sets them all the same. It used to show a single offset, always 0. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 581f6747e..329161e71 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -43,6 +43,8 @@ pub struct SelInfo { /// Uniform corner (shape, size) — the first corner. pub corner: (designcraft_geom::corners::CornerShape, f64), pub wrap_invert: bool, + /// Text wrap offsets: top, left, bottom, right. + pub wrap_offsets: [f64; 4], pub locked: bool, } @@ -98,6 +100,7 @@ pub fn sel_info(app: &DesignApp) -> Option { .unwrap_or_else(|| "solid".into()), corner: (first.corners.corners[0].shape, first.corners.corners[0].size), wrap_invert: first.wrap.invert, + wrap_offsets: first.wrap.offsets, locked: first.locked, }) } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..0729bd9a3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1757,27 +1757,76 @@ fn arrow_preview(ui: &mut egui::Ui, a: Arrowhead, start: bool, color: egui::Colo pub fn wrap_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let info = sel_info(app); + let mode = info.as_ref().map_or("none", |i| i.wrap); + // As InDesign's Text Wrap panel: the five wrap buttons and Invert, then the four offsets + // (top and bottom, left and right) with the link button that makes them all the same. ui.horizontal(|ui| { - for (icon, mode, tip) in [ + for (icon, m, tip) in [ ("wrap-none", "none", "No Text Wrap"), ("wrap-bbox", "boundingBox", "Wrap Around Bounding Box"), ("wrap-contour", "contour", "Wrap Around Object Shape"), ("wrap-jump", "jumpObject", "Jump Object"), ("wrap-next", "jumpToNextColumn", "Jump to Next Column"), ] { - let on = info.as_ref().is_some_and(|i| i.wrap == mode); - if icons::button(ui, icon, 26.0, on, crate::i18n::tr(&app.ui.language, tip)).clicked() { - let _ = app.run("object.textWrap", json!({"mode": mode})); + if icons::button(ui, icon, 26.0, mode == m, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("object.textWrap", json!({"mode": m})); } } + ui.add_space(6.0); + let mut inv = info.as_ref().is_some_and(|i| i.wrap_invert); + let r = ui.add_enabled_ui(mode != "none", |ui| widgets::checkbox(ui, &mut inv, crate::i18n::tr(&app.ui.language, "Invert"))).inner; + if r.changed() { + let _ = app.run("object.textWrap", json!({"mode": mode, "invert": inv})); + } }); let u = units(app); - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Offset")); - if let Some(v) = measure(ui, "wo", Some(0.0), u, 60.0) { - let _ = app.run("object.textWrap", json!({"mode": info.as_ref().map(|i| i.wrap).unwrap_or("boundingBox"), "offset": v})); + let offsets = info.as_ref().map(|i| i.wrap_offsets); + // Linked while the offsets are all the same, until the link is clicked. + let linked_id = egui::Id::new("wrap_offsets_linked"); + let linked = ui.data(|d| d.get_temp::(linked_id)).unwrap_or_else(|| offsets.is_none_or(|o| o.iter().all(|v| (v - o[0]).abs() < 1e-9))); + let mut set: Option<[f64; 4]> = None; + ui.add_enabled_ui(mode != "none" && info.is_some(), |ui| { + // Two rows of fixed-width cells: Top | Left, then Bottom | Right; the link between them. + let cell = |ui: &mut Ui, w: f32, label: &str| { + let x0 = ui.cursor().min.x; + caption(ui, crate::i18n::tr(&app.ui.language, label)); + ui.add_space((w - (ui.cursor().min.x - x0)).max(0.0)); + }; + // The second column starts where the first row's does (after the link button). + let mut col2_x: Option = None; + for (r, row) in [[(0, "Top"), (1, "Left")], [(2, "Bottom"), (3, "Right")]].into_iter().enumerate() { + ui.horizontal(|ui| { + for (k, (side, label)) in row.into_iter().enumerate() { + if k == 1 { + match col2_x { + Some(x) => ui.add_space((x - ui.cursor().min.x).max(0.0)), + None => col2_x = Some(ui.cursor().min.x), + } + } + cell(ui, if k == 0 { 56.0 } else { 40.0 }, label); + if let Some(v) = measure(ui, &format!("wo{side}"), offsets.map(|o| o[side]), u, 60.0) { + let mut o = offsets.unwrap_or([0.0; 4]); + if linked { + o = [v; 4]; + } else { + o[side] = v; + } + set = Some(o); + } + if k == 0 && r == 0 { + let tip = crate::i18n::tr(&app.ui.language, "Make all settings the same"); + let link = widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, vec2(22.0, 21.0)); + if link.clicked() { + ui.data_mut(|d| d.insert_temp(linked_id, !linked)); + } + } + } + }); } }); + if let Some(o) = set { + let _ = app.run("object.textWrap", json!({"mode": mode, "offset": o})); + } } /// The Pathfinder operations, in the order of InDesign's Pathfinder buttons. @@ -2480,3 +2529,47 @@ mod effects_panel_tests { assert_eq!(st.history.undo.len(), undo, "disabled controls do not create undo steps"); } } + +#[cfg(test)] +mod wrap_panel_tests { + use super::*; + + /// One frame of the Text Wrap panel: the text painted and where. + fn frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(360.0, 300.0)); + let time = ctx.input(|i| i.time) + 1.0 / 60.0; + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| wrap_panel(app, ui)); + out.textures_delta.clear(); + out.shapes + .into_iter() + .filter_map(|s| if let egui::Shape::Text(t) = s.shape { Some((t.galley.job.text.clone(), t.visual_bounding_rect())) } else { None }) + .collect() + } + + #[test] + fn the_text_wrap_panel_shows_each_offset_and_invert() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 144, 144]})).unwrap()["id"].as_u64().unwrap(); + app.run("object.textWrap", json!({"mode": "boundingBox", "offset": [1, 2, 3, 4]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + // Top and Left, then Bottom and Right, each with its own value (picas here: 0p1 = 1 pt). + let t: Vec = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).filter(|t| !t.is_empty()).collect(); + assert_eq!(t, ["Invert", "Top", "0p1", "Left", "0p2", "Bottom", "0p3", "Right", "0p4"]); + // Invert, next to the wrap buttons. + let pos = frame(&mut app, &ctx, vec![]).into_iter().find(|(t, _)| t == "Invert").unwrap().1.center(); + for pressed in [true, false] { + frame( + &mut app, + &ctx, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + assert!(app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().wrap.invert); + } +} From 027c986b65c85d73a45cbc4bd47fcc57bc25b2d7 Mon Sep 17 00:00:00 2001 From: haranishi <223786623+haranishi@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:54:37 +0900 Subject: [PATCH 022/128] =?UTF-8?q?test:=20Links=E3=83=91=E3=83=8D?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E3=83=87=E3=83=90=E3=83=83=E3=82=B0=E7=94=A8?= =?UTF-8?q?=E6=9B=B8=E3=81=8D=E5=87=BA=E3=81=97=E3=82=92=E9=99=A4=E5=8E=BB?= =?UTF-8?q?=20(#703)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/panels/properties.rs | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index aa612313c..33bc24253 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2306,10 +2306,8 @@ mod tests { } fn links_harness(app: DesignApp, width: f32) -> Harness<'static, DesignApp> { - let builder = Harness::builder().with_size(vec2(width, 600.0)); - let builder = if std::env::var_os("DESIGNCRAFT_LINKS_FIXTURES").is_some() { builder.wgpu() } else { builder }; let mut ready = false; - let mut h = builder.build_ui_state( + let mut h = Harness::builder().with_size(vec2(width, 600.0)).build_ui_state( move |ui, app: &mut DesignApp| { if !ready { crate::theme::install_fonts(ui.ctx(), ""); @@ -2326,19 +2324,6 @@ mod tests { h } - fn capture_links(h: &mut Harness<'_, DesignApp>, name: &str) { - let Some(directory) = std::env::var_os("DESIGNCRAFT_LINKS_FIXTURES").map(std::path::PathBuf::from) else { return }; - std::fs::create_dir_all(&directory).unwrap(); - h.render().unwrap().save(directory.join(format!("round2-links569-{name}.png"))).unwrap(); - if name == "same-named-sources" { - let doc = &h.state().session.active().unwrap().doc; - let mut value = serde_json::to_value(doc).unwrap(); - value["assetData"] = - Value::Object(doc.assets.iter().map(|(id, a)| (id.0.to_string(), json!(designcraft_engine::cmd::base64_encode(&a.data)))).collect()); - std::fs::write(directory.join("round2-links569-synthetic.designcraft"), serde_json::to_vec(&value).unwrap()).unwrap(); - } - } - #[test] fn links_panel_distinguishes_same_named_sources_and_keeps_link_actions() { let first = "/project/assets/studio-a/photo.svg"; @@ -2347,10 +2332,8 @@ mod tests { let mut h = links_harness(app, 320.0); assert_eq!(h.get_all_by_label("photo.svg").count(), 3); assert!(h.query_by_label(first).is_some(), "the first source path is visible in the row"); - capture_links(&mut h, "same-named-sources"); h.get_by_label(second).click(); h.run_steps(3); - capture_links(&mut h, "selected-studio-b"); h.get_by_label("Go To").click(); h.run_steps(3); assert_eq!(h.state().session.active().unwrap().selection.items, vec![items[1]]); @@ -2361,10 +2344,8 @@ mod tests { assert!(h.query_by_label(second).is_none(), "an embedded graphic no longer shows a source path"); assert_eq!(h.get_all_by_label("Embedded").count(), 2); assert_eq!(h.get_all_by_label("photo.svg").count(), 3); - capture_links(&mut h, "embedded"); h.get_by_label(first).click(); h.run_steps(3); - capture_links(&mut h, "selected-studio-a"); h.get_by_label("Go To").click(); h.run_steps(3); assert_eq!(h.state().session.active().unwrap().selection.items, vec![items[0]]); @@ -2382,7 +2363,6 @@ mod tests { h.get_by_label(path).click(); h.run_steps(3); assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(text) if text.galley.job.text == path && !text.galley.elided && text.visual_bounding_rect().right() <= 240.0)), "the selected path is painted in full and wraps inside the panel"); - capture_links(&mut h, "long-path-narrow"); } #[test] From 3ac4d2e913e3664442f2591f0e51b9ab4abe9986 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:59:29 +1000 Subject: [PATCH 023/128] Preserve endnote and editorial payloads in text slices Co-Authored-By: Codex --- crates/doc/src/slice.rs | 148 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/slice.rs b/crates/doc/src/slice.rs index f85ec4adc..7bbb58f6b 100644 --- a/crates/doc/src/slice.rs +++ b/crates/doc/src/slice.rs @@ -1,8 +1,10 @@ //! Formatted text slices: copy a range of a story (with paragraph and character formats, -//! footnotes, cross-references, index markers and tables) and insert it into another story. +//! footnotes, endnotes, editorial notes, cross-references, index markers and tables) and insert +//! it into another story. use std::sync::Arc; +use crate::endnotes::{ENDNOTE_REF, NOTE_MARK}; use crate::ids::StoryId; use crate::index::INDEX_MARK; use crate::notes::FOOTNOTE_REF; @@ -33,6 +35,12 @@ impl Story { let count = |m: char, upto: usize| self.text[..upto].matches(m).count(); let span = |m: char| count(m, a)..count(m, b); out.notes = self.notes[span(FOOTNOTE_REF)].to_vec(); + // Malformed input can have fewer payloads than markers. Keep the available entries; + // insertion creates empty entries for any missing payloads. + let endnotes = span(ENDNOTE_REF); + out.endnotes = self.endnotes.iter().skip(endnotes.start).take(endnotes.len()).cloned().collect(); + let editorial = span(NOTE_MARK); + out.editorial = self.editorial.iter().skip(editorial.start).take(editorial.len()).cloned().collect(); out.anchors = self.anchors[span(ANCHOR_MARK)].to_vec(); out.xrefs = self.xrefs[span(XREF_MARK)].to_vec(); out.index_refs = self.index_refs[span(INDEX_MARK)].to_vec(); @@ -74,7 +82,7 @@ impl Story { *p = crate::story::ParaFormat { table: None, ..pf.clone() }; } } - // Payloads of footnotes, cross-references and index markers, in order. + // Marker payloads in text order, keeping newly allocated destination note ids. let first = |m: char| self.text[..pos].matches(m).count(); let (nf, nx, ni, na) = (first(FOOTNOTE_REF), first(XREF_MARK), first(INDEX_MARK), first(ANCHOR_MARK)); let no = first(crate::anchored::OBJECT_MARK); @@ -89,6 +97,22 @@ impl Story { *slot = Arc::new(crate::notes::Footnote { id, text: n.text.clone() }); } } + // Limit payload copies to inserted markers so excess source entries cannot overwrite + // notes already following the insertion point. + let ne = first(ENDNOTE_REF); + for (k, n) in src.endnotes.iter().take(src.text.matches(ENDNOTE_REF).count()).enumerate() { + if let Some(slot) = self.endnotes.get_mut(ne + k) { + let id = slot.id; + *slot = Arc::new(crate::notes::Footnote { id, text: n.text.clone() }); + } + } + let nn = first(NOTE_MARK); + for (k, n) in src.editorial.iter().take(src.text.matches(NOTE_MARK).count()).enumerate() { + if let Some(slot) = self.editorial.get_mut(nn + k) { + let id = slot.id; + *slot = Arc::new(crate::endnotes::EditorialNote { id, ..(**n).clone() }); + } + } for (k, x) in src.xrefs.iter().enumerate() { if let Some(slot) = self.xrefs.get_mut(nx + k) { *slot = x.clone(); @@ -159,4 +183,124 @@ mod tests { assert_eq!(part.notes.len(), 1); assert!(a.extract(2..8).notes.is_empty()); } + + fn append_note_pair(st: &mut Story, text: &str) { + st.insert_endnote(st.len(), text, ParaFormat::default()); + st.insert(st.len(), &NOTE_MARK.to_string()); + let note = Arc::make_mut(st.editorial.last_mut().unwrap()); + note.text = text.into(); + note.author = format!("{text} author"); + } + + #[test] + fn clipped_endnotes_and_editorial_keep_payloads_with_fresh_destination_ids() { + let mut source = Story::new(StoryId(1)); + append_note_pair(&mut source, "outside before"); + let start = source.len(); + append_note_pair(&mut source, "Citation é\nsecond"); + let end = source.len(); + let citation = &mut Arc::make_mut(&mut source.endnotes[1]).text; + citation.format_chars(0..8, |f| f.over.size = Some(19.0)); + citation.paras[1].style = "Citation".into(); + append_note_pair(&mut source, "outside after"); + source.check().unwrap(); + let original = source.clone(); + let slice = source.extract(start..end); + slice.check().unwrap(); + assert_eq!(slice.text, format!("{ENDNOTE_REF}{NOTE_MARK}")); + assert_eq!(slice.endnotes, vec![source.endnotes[1].clone()]); + assert_eq!(slice.editorial, vec![source.editorial[1].clone()]); + + let mut dest = Story::new(StoryId(2)); + append_note_pair(&mut dest, "destination before"); + let at = dest.len(); + append_note_pair(&mut dest, "destination after"); + let before = dest.clone(); + assert_eq!(dest.insert_story(at, &slice), at + slice.len()); + dest.check().unwrap(); + assert_eq!(dest.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(3)); + assert_eq!(dest.endnotes[1].text, slice.endnotes[0].text); + assert_eq!(dest.editorial[1].text, "Citation é\nsecond"); + assert_eq!(dest.editorial[1].author, "Citation é\nsecond author"); + assert_eq!((dest.endnotes[1].id, dest.editorial[1].id), (3, 3)); + assert_eq!(dest.endnotes[0], before.endnotes[0]); + assert_eq!(dest.endnotes[2], before.endnotes[1]); + assert_eq!(dest.editorial[0], before.editorial[0]); + assert_eq!(dest.editorial[2], before.editorial[1]); + Arc::make_mut(&mut dest.endnotes[1]).text.insert(0, "changed"); + Arc::make_mut(&mut dest.editorial[1]).author = "changed".into(); + assert_eq!(source, original); + assert_eq!(slice.endnotes[0], original.endnotes[1]); + assert_eq!(slice.editorial[0], original.editorial[1]); + } + + #[test] + fn extract_tolerates_missing_endnote_and_editorial_payloads() { + let mut source = Story::new(StoryId(1)); + for text in ["before", "selected", "missing"] { + append_note_pair(&mut source, text); + } + let pair_len = ENDNOTE_REF.len_utf8() + NOTE_MARK.len_utf8(); + for available in [0, 1, 2] { + let mut damaged = source.clone(); + damaged.endnotes.truncate(available); + damaged.editorial.truncate(available); + let original = damaged.clone(); + let slice = damaged.extract(pair_len..damaged.len()); + assert_eq!(slice.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(2)); + let expected = if available == 2 { 1 } else { 0 }; + assert_eq!((slice.endnotes.len(), slice.editorial.len()), (expected, expected)); + if expected == 1 { + assert_eq!(slice.endnotes[0], source.endnotes[1]); + assert_eq!(slice.editorial[0], source.editorial[1]); + } + let mut dest = Story::new(StoryId(2)); + append_note_pair(&mut dest, "untouched"); + let tail = dest.clone(); + dest.insert_story(0, &slice); + dest.check().unwrap(); + assert_eq!(dest.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(3)); + assert_eq!((dest.endnotes.len(), dest.editorial.len()), (3, 3)); + assert_eq!(dest.endnotes[2], tail.endnotes[0]); + assert_eq!(dest.editorial[2], tail.editorial[0]); + assert_eq!(dest.endnotes[0].text.text, if expected == 1 { "selected" } else { "" }); + assert_eq!(dest.editorial[0].text, if expected == 1 { "selected" } else { "" }); + assert!(dest.endnotes[1].text.is_empty()); + assert!(dest.editorial[1].text.is_empty()); + assert_eq!(damaged, original); + } + } + + #[test] + fn excess_note_payloads_do_not_overwrite_destination_neighbors() { + let mut payload = Story::new(StoryId(1)); + append_note_pair(&mut payload, "copied"); + for has_markers in [false, true] { + let mut src = if has_markers { payload.clone() } else { Story::with_text(StoryId(1), "plain", ParaFormat::default()) }; + src.endnotes.push(payload.endnotes[0].clone()); + src.editorial.push(payload.editorial[0].clone()); + let original = src.clone(); + let mut dest = Story::new(StoryId(2)); + append_note_pair(&mut dest, "before"); + let at = dest.len(); + append_note_pair(&mut dest, "after"); + let before = dest.clone(); + assert_eq!(dest.insert_story(at, &src), at + src.len()); + dest.check().unwrap(); + let pair = format!("{ENDNOTE_REF}{NOTE_MARK}"); + assert_eq!(dest.text, format!("{pair}{}{pair}", src.text)); + let tail = if has_markers { 2 } else { 1 }; + assert_eq!((dest.endnotes.len(), dest.editorial.len()), (tail + 1, tail + 1)); + assert_eq!(dest.endnotes[0], before.endnotes[0]); + assert_eq!(dest.endnotes[tail], before.endnotes[1]); + assert_eq!(dest.editorial[0], before.editorial[0]); + assert_eq!(dest.editorial[tail], before.editorial[1]); + if has_markers { + assert_eq!(dest.endnotes[1].text, payload.endnotes[0].text); + assert_eq!((dest.editorial[1].text.as_str(), dest.editorial[1].author.as_str()), ("copied", "copied author")); + assert_eq!((dest.endnotes[1].id, dest.editorial[1].id), (3, 3)); + } + assert_eq!(src, original); + } + } } From 7b33c981dca5e5204af14162a4bf9cf8c38987e5 Mon Sep 17 00:00:00 2001 From: Jeffrey Thayne <36243577+thaynej@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:30:40 -0600 Subject: [PATCH 024/128] Links: only read a linked text file when its size or time changed The Links panel lists links about twice a second, and each listing hashed every linked text file. A linked text file now also remembers its modification time (as Data Merge does for its source, through the same size-and-time fingerprint): while size and time match, it isn't read. A different size is Modified without reading. When only the time changed, the content hash still decides (a file saved or synced again without changes stays OK), and the session remembers that check by size and time, so the next poll doesn't read the file again. Update and Relink record the size and time from before their read. Test: polls of an unchanged file don't read it (it is made unreadable), a touched but unchanged file is read once and stays OK, a changed file is Modified. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/story.rs | 8 ++ crates/engine/src/cmd/datamerge/mod.rs | 6 + crates/engine/src/cmd/datamerge/parse.rs | 8 +- crates/engine/src/cmd/file.rs | 5 +- crates/engine/src/cmd/links.rs | 134 +++++++++++++++++------ crates/engine/src/cmd/place_text.rs | 25 ++++- crates/engine/src/lib.rs | 4 + 7 files changed, 150 insertions(+), 40 deletions(-) diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 22562340d..0fbb10ced 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -169,6 +169,10 @@ pub struct TextFileLink { /// The file's size and FNV-1a hash (hex) when it was placed or last updated. pub size: u64, pub hash: String, + /// Its modification time then (seconds since 1970; 0: unknown), as Data Merge keeps for its + /// source: while size and time are unchanged, the Links panel doesn't read the file. + #[serde(default, skip_serializing_if = "is_zero_i64")] + pub mtime: i64, /// Imported style name → document style (`styleMap`), including the names Auto Rename chose. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub style_map: BTreeMap, @@ -183,6 +187,10 @@ pub struct TextFileLink { pub story_hash: String, } +fn is_zero_i64(v: &i64) -> bool { + *v == 0 +} + impl Story { pub fn new(id: StoryId) -> Self { Story { diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index 3b3e1dd3b..015bca346 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -24,6 +24,12 @@ use parse::{ tile, }; +pub(crate) use parse::fingerprint_from; + +pub(crate) fn fingerprint_from_path(path: &str) -> Option { + fingerprint_of(Path::new(path)) +} + const PARENT_WARNING: &str = "A parent-page placeholder was left unfilled."; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index 6ab10f3d9..e9dcf4d9c 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -330,14 +330,18 @@ pub fn relative_between(from_dir: &Path, target: &Path) -> String { } pub fn fingerprint_of(path: &Path) -> Option { - let meta = std::fs::metadata(path).ok()?; + Some(fingerprint_from(&std::fs::metadata(path).ok()?)) +} + +/// Size and modification time (whole seconds) from a file's metadata. +pub fn fingerprint_from(meta: &std::fs::Metadata) -> Fingerprint { let mtime = meta .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) .unwrap_or(0); - Some(Fingerprint { size: meta.len(), mtime }) + Fingerprint { size: meta.len(), mtime } } pub fn status_of(path: &Path, previous: Option) -> SourceStatus { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8cfa4327f..e4d61d3f6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -264,9 +264,12 @@ fn file_save(s: &mut Session, p: &Value) -> Result { } fn file_place(s: &mut Session, p: &Value) -> Result { + // The file's size and time before it is read (a linked text file remembers them). + let mut before = None; let (bytes, name, link) = if let Some(b) = str_param(p, "base64") { (base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None) } else if let Some(path) = str_param(p, "path") { + before = super::datamerge::fingerprint_from_path(path); #[cfg(not(target_arch = "wasm32"))] let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; #[cfg(target_arch = "wasm32")] @@ -295,7 +298,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { } // Text files (plain, Word, RTF) flow into frames. if designcraft_textimport::is_text_file(&name) { - return super::place_text::place_text(s, p, &name, &bytes, link); + return super::place_text::place_text(s, p, &name, &bytes, link, before); } // Image Import Options: which page of a PDF (1-based). let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index e1c482a07..1195d1f3f 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -10,7 +10,7 @@ use std::sync::Arc; -use designcraft_doc::{AssetId, Content, Document, ItemId, Selection, Story, StoryId, TextFileLink}; +use designcraft_doc::{AssetId, Content, Document, Fingerprint, ItemId, Selection, Story, StoryId, TextFileLink}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; @@ -19,7 +19,8 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { - Ok(Value::Array(list(&s.doc()?.doc))) + let d = s.doc()?.doc.clone(); + Ok(Value::Array(list(&d, &mut s.text_link_checks))) }), cmd!( "links.relink", @@ -154,26 +155,45 @@ pub(crate) fn checkable_size(len: usize) -> bool { len as u64 <= MAX_TEXT_LINK_BYTES } +/// Checked linked text files: path → (size and time, hash of the contents then). +pub(crate) type TextChecks = std::collections::HashMap; + /// Link status of a linked text file: `tooLarge` when it is the recorded size but over -/// [`MAX_TEXT_LINK_BYTES`], so its contents aren't checked. -pub fn text_status(l: &TextFileLink) -> &'static str { +/// [`MAX_TEXT_LINK_BYTES`], so its contents aren't checked. The file is read only when its size +/// is the recorded one but its time isn't, and then once per new time (`checks`): a file saved +/// or synced again without changes stays OK. +pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks) -> &'static str { #[cfg(not(target_arch = "wasm32"))] { - match std::fs::metadata(&l.path) { - Err(_) => "missing", - Ok(m) if !m.is_file() => "missing", - Ok(m) if m.len() != l.size => "modified", - Ok(m) if m.len() > MAX_TEXT_LINK_BYTES => "tooLarge", - Ok(_) => match read_text(&l.path) { - Ok(b) if fnv_hex(&b) == l.hash => "ok", - Ok(_) => "modified", - Err(_) => "missing", - }, + let now = match std::fs::metadata(&l.path) { + Err(_) => return "missing", + Ok(m) if !m.is_file() => return "missing", + Ok(m) => super::datamerge::fingerprint_from(&m), + }; + if now.size != l.size { + return "modified"; } + if l.mtime != 0 && now.mtime == l.mtime { + return "ok"; + } + let hash = match checks.get(&l.path) { + Some((at, hash)) if *at == now => hash.clone(), + _ if now.size > MAX_TEXT_LINK_BYTES => return "tooLarge", + _ => match read_text(&l.path) { + Ok((b, at)) => { + let hash = fnv_hex(&b); + // The time from before the read: a change during it is seen next time. + checks.insert(l.path.clone(), (at, hash.clone())); + hash + } + Err(_) => return "missing", + }, + }; + if hash == l.hash { "ok" } else { "modified" } } #[cfg(target_arch = "wasm32")] { - let _ = l; + let _ = (l, checks); "ok" } } @@ -182,11 +202,11 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) } -fn list(d: &Document) -> Vec { +fn list(d: &Document, checks: &mut TextChecks) -> Vec { let texts = d.stories.values().filter_map(|st| { let l = st.file_link.as_ref()?; let uses: Vec = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect(); - Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l), "edited": story_hash(st) != l.story_hash, "uses": uses})) + Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l, checks), "edited": story_hash(st) != l.story_hash, "uses": uses})) }); let used = d.used_assets(); d.assets @@ -220,8 +240,9 @@ fn list(d: &Document) -> Vec { .collect() } +/// A linked text file's bytes, with its size and time from before they were read. #[cfg(not(target_arch = "wasm32"))] -fn read_text(path: &str) -> Result> { +fn read_text(path: &str) -> Result<(Vec, Fingerprint)> { use std::io::Read; let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}")); let m = std::fs::metadata(path).map_err(err)?; @@ -238,18 +259,18 @@ fn read_text(path: &str) -> Result> { if b.len() as u64 > MAX_TEXT_LINK_BYTES { return Err(too_large()); } - Ok(b) + Ok((b, super::datamerge::fingerprint_from(&m))) } #[cfg(target_arch = "wasm32")] -fn read_text(path: &str) -> Result> { +fn read_text(path: &str) -> Result<(Vec, Fingerprint)> { Err(EngineError::Other(format!("{path}: files can't be read on the web"))) } /// Import a linked text file again; a warning says when edits made in the layout were replaced. -fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8]) -> Result> { +fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fingerprint) -> Result> { let edited = d.story(sid).is_some_and(|st| st.file_link.as_ref().is_some_and(|l| story_hash(st) != l.story_hash)); - let mut warnings = super::place_text::reimport(d, sid, path, bytes)?; + let mut warnings = super::place_text::reimport(d, sid, path, bytes, Some(before))?; if edited { warnings.insert(0, format!("{}: edits made to story {} in the layout were replaced by the file's text", file_name(path), sid.0)); } @@ -271,10 +292,10 @@ fn reload(path: &str) -> Result<(Vec, (u32, u32))> { fn relink(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string(); if let Some(sid) = story_param(p) { - let bytes = read_text(&path)?; + let (bytes, before) = read_text(&path)?; let name = file_name(&path); return s.edit(|d, _| { - let warnings = reimport(d, sid, &path, &bytes)?; + let warnings = reimport(d, sid, &path, &bytes, before)?; Ok(json!({"name": name, "warnings": warnings})) }); } @@ -330,7 +351,8 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { } fn update(s: &mut Session, p: &Value) -> Result { - let d = &s.doc()?.doc; + let d = s.doc()?.doc.clone(); + let checks = &mut s.text_link_checks; let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p)); let all = only_asset.is_none() && only_story.is_none(); let which: Vec<(AssetId, String)> = d @@ -344,7 +366,7 @@ fn update(s: &mut Session, p: &Value) -> Result { .stories .values() .filter(|st| all || only_story == Some(st.id)) - .filter_map(|st| st.file_link.as_ref().filter(|l| text_status(l) == "modified").map(|l| (st.id, l.path.clone()))) + .filter_map(|st| st.file_link.as_ref().filter(|l| text_status(l, checks) == "modified").map(|l| (st.id, l.path.clone()))) .collect(); let mut fresh = Vec::new(); for (aid, path) in which { @@ -352,8 +374,8 @@ fn update(s: &mut Session, p: &Value) -> Result { } let mut fresh_texts = Vec::new(); for (sid, path) in texts { - let bytes = read_text(&path)?; - fresh_texts.push((sid, path, bytes)); + let (bytes, before) = read_text(&path)?; + fresh_texts.push((sid, path, bytes, before)); } let n = fresh.len() + fresh_texts.len(); if n == 0 { @@ -368,8 +390,8 @@ fn update(s: &mut Session, p: &Value) -> Result { } } let mut warnings = Vec::new(); - for (sid, path, bytes) in &fresh_texts { - warnings.extend(reimport(d, *sid, path, bytes)?); + for (sid, path, bytes, before) in &fresh_texts { + warnings.extend(reimport(d, *sid, path, bytes, *before)?); } Ok(json!({"updated": n, "warnings": warnings})) }) @@ -684,7 +706,7 @@ mod text_link_tests { // Saved and opened again: the link and its options are kept. let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); assert_eq!(back.story(sid).unwrap().file_link, story(&s, sid).file_link); - assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap()), "ok"); + assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap(), &mut Default::default()), "ok"); // The file moves: Missing; Relink to it imports it into the story. let moved = dir.join("moved").join("chapter.docx"); @@ -705,6 +727,52 @@ mod text_link_tests { let _ = std::fs::remove_dir_all(&dir); } + #[cfg(unix)] + #[test] + fn unchanged_linked_text_files_are_not_read_again() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("dc-text-link-poll-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("note.txt"); + std::fs::write(&path, "Hello").unwrap(); + // Unreadable: a status that reads the file would say missing. + let readable = |on: bool| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(if on { 0o644 } else { 0o000 })).unwrap(); + let touch = |secs: u64| { + let f = std::fs::File::options().write(true).open(&path).unwrap(); + f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(secs)).unwrap(); + }; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true})).unwrap(); + let status = |s: &mut Session| text_rows(s)[0]["status"].as_str().unwrap().to_string(); + + // Size and time as placed: not read. + readable(false); + if std::fs::read(&path).is_ok() { + // Running as root: permissions don't stop reads, so this can't be observed. + return; + } + assert_eq!(status(&mut s), "ok"); + // Saved again unchanged (a new time): read once, then not again. + readable(true); + touch(10); + assert_eq!(status(&mut s), "ok"); + readable(false); + assert_eq!(status(&mut s), "ok", "the second poll doesn't read it"); + // Changed, same size, new time: modified. + readable(true); + std::fs::write(&path, "Jello").unwrap(); + touch(20); + assert_eq!(status(&mut s), "modified"); + // Updated: the new size and time are kept, so the next poll doesn't read it. + s.execute("links.update", &json!({})).unwrap(); + readable(false); + assert_eq!(status(&mut s), "ok"); + readable(true); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn create_links_when_placing_text_files_preference() { let dir = std::env::temp_dir().join(format!("dc-text-link-pref-{}", std::process::id())); @@ -762,12 +830,12 @@ mod text_link_read_tests { fn status_of(path: &std::path::Path, size: u64) -> &'static str { let l = TextFileLink { path: path.to_string_lossy().to_string(), size, hash: "0".into(), ..Default::default() }; - promptly(move || text_status(&l)) + promptly(move || text_status(&l, &mut Default::default())) } fn read(path: &std::path::Path) -> Result { let p = path.to_string_lossy().to_string(); - promptly(move || read_text(&p).map(|b| b.len()).map_err(|e| e.to_string())) + promptly(move || read_text(&p).map(|(b, _)| b.len()).map_err(|e| e.to_string())) } #[test] diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 4d4577a5f..1acff61c4 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -142,9 +142,12 @@ fn fill(story: &mut Story, from: &Story) { } /// The link a story placed from `path` keeps (Create Links When Placing Text Files). +/// `before`: the file's size and time before `bytes` were read (its time is kept only when the +/// size matches what was read). fn text_link( path: &str, bytes: &[u8], + before: Option, map: std::collections::HashMap, conflict: Conflict, remove: bool, @@ -154,6 +157,7 @@ fn text_link( path: path.to_string(), size: bytes.len() as u64, hash: super::links::fnv_hex(bytes), + mtime: before.filter(|f| f.size == bytes.len() as u64).map_or(0, |f| f.mtime), style_map: map.into_iter().collect(), style_conflicts: conflict.name().into(), remove_styles: remove, @@ -163,7 +167,13 @@ fn text_link( /// Update Link of a linked text file: `bytes` (read from `path`) imported again into the story /// with the import options it was placed with, keeping its frames. Returns the import's warnings. -pub(crate) fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8]) -> Result> { +pub(crate) fn reimport( + d: &mut Document, + sid: StoryId, + path: &str, + bytes: &[u8], + before: Option, +) -> Result> { let link = d.story(sid).and_then(|s| s.file_link.clone()).ok_or_else(|| bad("links.update", format!("story {} isn't linked to a file", sid.0)))?; let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()); @@ -181,7 +191,7 @@ pub(crate) fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8]) add_styles(d, &imp, conflict); let story = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; fill(story, &imp.story); - story.file_link = Some(text_link(path, bytes, map, conflict, link.remove_styles, story)); + story.file_link = Some(text_link(path, bytes, before, map, conflict, link.remove_styles, story)); Ok(imp.warnings) } @@ -234,7 +244,14 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Resu } } -pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8], path: Option) -> Result { +pub(super) fn place_text( + s: &mut Session, + p: &Value, + name: &str, + bytes: &[u8], + path: Option, + before: Option, +) -> Result { let mut imp = designcraft_textimport::import(name, bytes).map_err(|e| bad("file.place", e.to_string()))?; let remove = p.get("removeStyles").and_then(Value::as_bool).unwrap_or(false); if remove { @@ -310,7 +327,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8], p }; let story = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; fill(story, &imp.story); - story.file_link = link.as_deref().map(|path| text_link(path, bytes, map.clone(), conflict, remove, story)); + story.file_link = link.as_deref().map(|path| text_link(path, bytes, before, map.clone(), conflict, remove, story)); *sel = designcraft_doc::Selection::items(vec![fid]); sid }; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 82b4a59af..3b6471abe 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -286,6 +286,9 @@ pub struct Session { pub book: Option, /// Content Collector conveyor: collected objects as snippets (name, bytes). pub conveyor: Vec<(String, Vec)>, + /// Linked text files checked by the Links panel: path → the size and modification time the + /// file had and the hash of its contents then, so a file touched but not changed is read once. + pub(crate) text_link_checks: std::collections::HashMap, } impl Default for Session { @@ -307,6 +310,7 @@ impl Session { clipboard: None, recovery_dir: None, text_clipboard: None, + text_link_checks: Default::default(), ui_requests: vec![], loaded: None, transforms: Default::default(), From 0541f367951d378a7340927fa63e85f61011cbc8 Mon Sep 17 00:00:00 2001 From: Jeffrey Thayne <36243577+thaynej@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:38:37 -0600 Subject: [PATCH 025/128] Links: don't trust a linked text file's time while it is still changing File times are whole seconds (as in Data Merge's fingerprint), so a same-size change saved within the same second as the last check kept both size and time, and the Links panel said OK. A size and time are now only remembered (on place, update, relink, and in the session's check cache) once the file has been unchanged for two seconds; a file changed more recently is checked by content on each poll. Found by the Update Link dialog test, which rewrites a file within the same second; the polling test now also changes a file twice in one second. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/links.rs | 31 ++++++++++++++++++++++++++--- crates/engine/src/cmd/place_text.rs | 4 ++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 1195d1f3f..a28d49e73 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -155,6 +155,21 @@ pub(crate) fn checkable_size(len: usize) -> bool { len as u64 <= MAX_TEXT_LINK_BYTES } +/// Can a size and time be trusted to change when the file does? Times are whole seconds, so not +/// while the file was changed in the last two: another change in that second keeps the time. +pub(crate) fn settled(f: &Fingerprint) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)); + now.saturating_sub(f.mtime) >= 2 + } + #[cfg(target_arch = "wasm32")] + { + let _ = f; + false + } +} + /// Checked linked text files: path → (size and time, hash of the contents then). pub(crate) type TextChecks = std::collections::HashMap; @@ -183,7 +198,9 @@ pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks) -> &'static Ok((b, at)) => { let hash = fnv_hex(&b); // The time from before the read: a change during it is seen next time. - checks.insert(l.path.clone(), (at, hash.clone())); + if settled(&at) { + checks.insert(l.path.clone(), (at, hash.clone())); + } hash } Err(_) => return "missing", @@ -738,10 +755,12 @@ mod text_link_tests { std::fs::write(&path, "Hello").unwrap(); // Unreadable: a status that reads the file would say missing. let readable = |on: bool| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(if on { 0o644 } else { 0o000 })).unwrap(); + // A time `secs` ago (a file changed in the last two seconds is always read). let touch = |secs: u64| { let f = std::fs::File::options().write(true).open(&path).unwrap(); - f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(secs)).unwrap(); + f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(secs)).unwrap(); }; + touch(60); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true})).unwrap(); @@ -756,7 +775,7 @@ mod text_link_tests { assert_eq!(status(&mut s), "ok"); // Saved again unchanged (a new time): read once, then not again. readable(true); - touch(10); + touch(30); assert_eq!(status(&mut s), "ok"); readable(false); assert_eq!(status(&mut s), "ok", "the second poll doesn't read it"); @@ -765,6 +784,12 @@ mod text_link_tests { std::fs::write(&path, "Jello").unwrap(); touch(20); assert_eq!(status(&mut s), "modified"); + // Changed again in the same second, same size: still seen (just-changed files are read). + std::fs::write(&path, "Hello").unwrap(); + assert_eq!(status(&mut s), "ok"); + std::fs::write(&path, "Jello").unwrap(); + assert_eq!(status(&mut s), "modified"); + touch(10); // Updated: the new size and time are kept, so the next poll doesn't read it. s.execute("links.update", &json!({})).unwrap(); readable(false); diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 1acff61c4..c6d682248 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -143,7 +143,7 @@ fn fill(story: &mut Story, from: &Story) { /// The link a story placed from `path` keeps (Create Links When Placing Text Files). /// `before`: the file's size and time before `bytes` were read (its time is kept only when the -/// size matches what was read). +/// size matches what was read and it can be trusted, see [`super::links::settled`]). fn text_link( path: &str, bytes: &[u8], @@ -157,7 +157,7 @@ fn text_link( path: path.to_string(), size: bytes.len() as u64, hash: super::links::fnv_hex(bytes), - mtime: before.filter(|f| f.size == bytes.len() as u64).map_or(0, |f| f.mtime), + mtime: before.filter(|f| f.size == bytes.len() as u64 && super::links::settled(f)).map_or(0, |f| f.mtime), style_map: map.into_iter().collect(), style_conflicts: conflict.name().into(), remove_styles: remove, From 2d5abba78f23f735bc7c48ce5aea9622ddd3b418 Mon Sep 17 00:00:00 2001 From: Nick Marello <69686627+Nmarello@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:02:47 -0700 Subject: [PATCH 026/128] #21: opening or dropping an InDesign file says how to bring it over file.open on a .indd reported "not a DesignCraft document: expected value at line 1 column 1"; file.openBytes with a .indd name failed the same way, and a dropped .indd was placed as a graphic. All now say InDesign documents and templates can't be opened yet and point to File > Export > InDesign Markup (IDML), which DesignCraft already opens. The check is by extension, before anything is read. Also adds my contributors/people.toml entry. Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 5 +++++ crates/engine/src/cmd/file.rs | 36 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 6 ++++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 67a8fdf30..8145ee287 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -30,3 +30,8 @@ sync_github_name = true [people.thaynej] real_name = "Jeffrey Thayne" + +[people.Nmarello] +real_name = "Nick Marello" +display_name = "Nick Marello" +sync_github_name = false diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a1919b89e..b9ad7f055 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -157,8 +157,24 @@ fn file_sample(s: &mut Session, _p: &Value) -> Result { Ok(json!({"index": i})) } +/// An InDesign file that DesignCraft can't open yet, with how to bring it over. Decoding one as a +/// DesignCraft document only reported "not a DesignCraft document: expected value at line 1 +/// column 1" (#21). +fn foreign_document(name: &str) -> Option<&'static str> { + let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str())?.to_ascii_lowercase(); + match ext.as_str() { + "indd" | "indt" => Some( + "InDesign documents (.indd) and templates (.indt) can't be opened yet. In InDesign, choose File ▸ Export… ▸ InDesign Markup (IDML) and open the .idml in DesignCraft.", + ), + _ => None, + } +} + fn file_open(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; + if let Some(hint) = foreign_document(path) { + return Err(EngineError::Other(format!("{path}: {hint}"))); + } #[cfg(not(target_arch = "wasm32"))] { if path.to_ascii_lowercase().ends_with(".idml") { @@ -219,6 +235,11 @@ fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf { } fn file_open_bytes(s: &mut Session, p: &Value) -> Result { + if let Some(name) = str_param(p, "name") + && let Some(hint) = foreign_document(name) + { + return Err(EngineError::Other(format!("{name}: {hint}"))); + } let b = base64_decode(str_param(p, "base64").unwrap_or("")); // IDML packages are recognised by their stored `mimetype` first entry. if designcraft_idml::is_idml(&b) { @@ -866,6 +887,21 @@ mod document_fonts_tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn an_indesign_file_says_how_to_bring_it_over() { + // The extension decides (any case), not the bytes: no InDesign-made file is needed. + let dir = temp_dir("indd"); + let path = dir.join("Brochure.INDD"); + std::fs::write(&path, b"not a DesignCraft document").unwrap(); + let mut s = Session::new(); + let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("InDesign Markup (IDML)") && !e.contains("expected value"), "{e}"); + let e = s.execute("file.openBytes", &json!({"name": "Brochure.indt", "base64": ""})).unwrap_err().to_string(); + assert!(e.contains("InDesign Markup (IDML)"), "{e}"); + assert!(s.doc().is_err(), "nothing was opened"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn the_document_fonts_folder_is_found_whatever_its_case() { const FAMILY: &str = "DocFont Case Test"; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..00712f052 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1230,7 +1230,9 @@ impl DesignApp { /// 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)) + // InDesign files go to the open path too, which says how to bring them over (as IDML) + // instead of failing to place them as a graphic. + [".designcraft", ".idml", ".indd", ".indt"].iter().any(|ext| n.ends_with(ext)) } pub fn now_ms() -> f64 { @@ -1856,7 +1858,7 @@ mod tests { #[test] fn dropped_documents_open_and_the_rest_is_placed() { - for name in ["a.designcraft", "B.IDML"] { + for name in ["a.designcraft", "B.IDML", "c.indd", "D.INDT"] { assert!(opens_as_document(name), "{name}"); } for name in ["a.png", "b.pdf", "idml.txt", "c.idml.zip"] { From e329cb80237b39b04d0c6da347418407502ab8df Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:18:08 +0200 Subject: [PATCH 027/128] Fix overset preflight page for frames inside translated groups (#738) --- crates/engine/src/cmd/preflight.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index f5ee8dfdf..e4ace2b6b 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -88,7 +88,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { missing_glyphs(&cs, &story.text, 0, &mut missing); if cs.is_overset() { let last = story.frames.last().copied(); - let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, Affine::IDENTITY)); + let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, d.parent_xf(&loc))); let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count(); out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page }); } @@ -329,3 +329,29 @@ mod missing_glyph_tests { assert!(found[0].1.ends_with(" and 10 more"), "{}", found[0].1); } } + +#[cfg(test)] +mod overset_page_tests { + use serde_json::json; + + use crate::Session; + + /// An overset frame inside a group moved onto the right-hand page was reported on the left one. + #[test] + fn overset_frame_in_a_moved_group_names_its_displayed_page() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": true, "pages": 3, "width": 612, "height": 792})).unwrap(); + let frame = json!({"spread": 1, "rect": [72, 72, 140, 100], "content": "text", "text": "overflowing words ".repeat(100)}); + let a = s.execute("frame.create", &frame).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"spread": 1, "rect": [300, 300, 350, 350]})).unwrap()["id"].as_u64().unwrap(); + let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + let overset_page = |s: &mut Session| { + let r = s.execute("preflight.run", &json!({})).unwrap(); + let issue = r["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "overset").unwrap().clone(); + issue["page"].as_u64().unwrap() + }; + assert_eq!(overset_page(&mut s), 1); + s.execute("transform.move", &json!({"dx": 612, "dy": 0, "ids": [g]})).unwrap(); + assert_eq!(overset_page(&mut s), 2); + } +} From dfb41f5aea569e06aac0dfd09b2fe4ffe1fbbd3a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:42:57 +0200 Subject: [PATCH 028/128] fix(cli): reject --pdf-options that is not a JSON object instead of panicking (#731) --- apps/designcraft-cli/src/main.rs | 6 +++++- apps/designcraft-cli/tests/cli.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index dd62cd91d..4bee26a79 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -261,7 +261,11 @@ fn run(args: &[String]) -> Result<(), String> { "--page" => page = val()?.parse().map_err(|_| "bad --page")?, "--scale" => scale = val()?.parse().map_err(|_| "bad --scale")?, "--pdf-options" => { - pdf_opts = serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))?; + pdf_opts = match serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))? { + Value::Null => json!({}), + o @ Value::Object(_) => o, + _ => return Err("--pdf-options: expected a JSON object".to_string()), + }; } "--export" => { let out = val()?; diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 92f68ab56..b2eed1b75 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -138,6 +138,24 @@ fn run_export_options_come_before_their_export() { let _ = std::fs::remove_dir_all(&dir); } +/// `--pdf-options` must be a JSON object; any other JSON value used to panic when the PDF path was set on it. +#[test] +fn pdf_options_that_are_not_an_object_are_an_error_not_a_panic() { + let dir = std::env::temp_dir().join(format!("dc-cli-pdfopts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let pdf = dir.join("out.pdf"); + let pdf = pdf.to_str().unwrap(); + for val in ["[]", "true", "1", r#""x""#] { + let out = cli().args(["run", "--sample", "--pdf-options", val, "--export", pdf]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "{val}: {err}"); + assert!(err.contains("--pdf-options") && !err.contains("panicked"), "{val}: {err}"); + } + let out = cli().args(["run", "--sample", "--pdf-options", "null", "--export", pdf]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let _ = std::fs::remove_dir_all(&dir); +} + #[cfg(unix)] #[test] fn raster_export_preserves_an_existing_reader_and_private_symlink_target() { From cab9200975ef1b6edd5afe97ad10f8e8cf5266f9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:43:09 +0200 Subject: [PATCH 029/128] fix: eyedropper ignores inactive states of a multi-state object (#737) --- crates/tools/src/tests.rs | 51 +++++++++++++++++++++++++++++++++++++++ crates/tools/src/xform.rs | 6 +++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6776902aa..31fd04540 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -876,3 +876,54 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))]); } + +#[test] +fn eyedropper_ignores_inactive_states_of_a_multi_state_object() { + use designcraft_doc::{Asset, AssetId, Content, Graphic}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.assets.insert( + AssetId(1), + std::sync::Arc::new(Asset { + id: AssetId(1), + name: "photo.png".into(), + mime: "image/png".into(), + link: None, + data: std::sync::Arc::new(vec![]), + pixels: Some((100, 100)), + page: 0, + }), + ); + let area = Rect::new(100.0, 100.0, 200.0, 200.0); + let raster = { + let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); + it.content = Content::Graphic(Graphic { + asset: AssetId(1), + size: (100.0, 100.0), + xf: designcraft_geom::Affine::translate((0.0, 0.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + it + }; + let mut vector = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); + vector.fill.swatch = "[Black]".into(); + let target = add_rect(&mut d, Rect::new(400.0, 100.0, 450.0, 150.0)); + let s = Selection { items: vec![target], ..Default::default() }; + let sample = Action::Exec("color.sample".into(), serde_json::json!({"at": [120.0, 130.0], "spread": {"kind": "doc", "index": 0}})); + // Children [vector, raster] with the raster on top: it samples only while it is the active state. + for (active, samples) in [(1, true), (0, false)] { + let mut group = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); + group.content = Content::Group { items: vec![std::sync::Arc::new(vector.clone()), std::sync::Arc::new(raster.clone())] }; + group.states = vec!["a".into(), "b".into()]; + group.active_state = active; + let mut d2 = d.clone(); + d2.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d2, false)); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let cx = ctx(&d2, &s, &c, &l); + let a = create("eyedropper").pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!(a.contains(&sample), samples, "active state {active}: {a:?}"); + } +} diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index e0bcd24f3..37d2acf82 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -257,8 +257,10 @@ fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { return None; } let xf = outer * it.xf; - if let Content::Group { items } = &it.content { - return items.iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1)); + if let Content::Group { .. } = &it.content { + // Only the shown children count: a multi-state object shows just its active state. + let shown: Vec<&Item> = it.shown_children().map(|c| &**c).collect(); + return shown.into_iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1)); } let graphic = matches!(it.content, Content::Graphic(_)); // Unfilled frames (a text frame over the photo) let the image show through. From efd2310c83c6936307153415e494c8704670cf09 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 13:20:47 -0600 Subject: [PATCH 030/128] refactor(pdf): share writer backend (PDFW) --- Cargo.lock | 14 +- Cargo.toml | 6 +- ROADMAP.md | 2 + crates/mcp/src/backend.rs | 2 + crates/pdf/Cargo.toml | 2 +- crates/pdf/src/export.rs | 153 ++++++++++++------ crates/pdf/src/lib.rs | 6 + crates/pdf/src/links.rs | 1 + crates/pdf/src/marks.rs | 1 + crates/pdf/src/pdfx.rs | 1 + crates/pdf/src/tests.rs | 23 +++ crates/pdf/src/text.rs | 13 +- .../support/image_content_cache_contract.rs | 2 + docs/architecture.md | 10 +- docs/pdf-backend.md | 34 ++++ xtask/src/main.rs | 4 + xtask/src/pdf_deps.rs | 87 ++++++++++ 17 files changed, 299 insertions(+), 62 deletions(-) create mode 100644 docs/pdf-backend.md create mode 100644 xtask/src/pdf_deps.rs diff --git a/Cargo.lock b/Cargo.lock index ef525c84e..21064be13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -796,6 +796,15 @@ dependencies = [ "libm", ] +[[package]] +name = "craft-pdf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8" +dependencies = [ + "krilla", + "thiserror 2.0.21", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -1079,6 +1088,7 @@ dependencies = [ name = "designcraft-pdf" version = "0.7.1" dependencies = [ + "craft-pdf", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -1089,7 +1099,6 @@ dependencies = [ "hayro-interpret", "hayro-syntax", "image", - "krilla", "krilla-svg", "kurbo", "log", @@ -2437,8 +2446,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33" +source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8" dependencies = [ "base64", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index b08a82fc2..14655b205 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] +# Shared writer; the immutable revision must be published before this consumer. +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false } craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } # internal designcraft-geom = { path = "crates/geom" } @@ -57,12 +59,11 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", tiff = "0.11" # SVG (same usvg as krilla-svg). resvg = { version = "0.47", default-features = false, features = ["text"] } -krilla-svg = "0.8" +krilla-svg = "=0.8.1" # Boolean operations on Bézier paths (Pathfinder). flo_curves = "0.8" # QR codes (Object › Generate QR Code); MIT. qrcodegen = "1.8" -krilla = { version = "0.8", features = ["pdf"] } hayro = "0.7" quick-xml = "0.38" zip = { version = "3", default-features = false, features = ["deflate"] } @@ -100,4 +101,5 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] +krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" } winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..2cd564f94 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12 (PDFW)** · PDF, booklet and book composition share guarded writing, metadata and font preparation through craft-pdf. Layout, shaping, placed graphics, colour policy, tagging and PDF/X postprocessing remain in DesignCraft. Failed native drawing returns an export error; failed writer cleanup cannot serialize an incomplete document. No readiness percentage change. + - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index debdbb124..0258bd7e4 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -172,6 +172,8 @@ mod tests { Err(e) => panic!("accept failed: {e}"), } }; + // Accepted sockets inherit nonblocking mode on macOS; only accept should poll. + stream.set_nonblocking(false).unwrap(); stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 47c384470..6f40462b7 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -14,7 +14,7 @@ designcraft-color = { workspace = true } designcraft-doc = { workspace = true } designcraft-fonts = { workspace = true } designcraft-compose = { workspace = true } -krilla = { workspace = true } +craft-pdf = { workspace = true, features = ["write-pdf"] } krilla-svg = { workspace = true } designcraft-images = { workspace = true } image = { workspace = true } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..f9d09b616 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -3,6 +3,8 @@ use std::collections::HashMap; use std::sync::Arc; +use craft_pdf::write::backend as krilla; +use craft_pdf::write::{self, Metadata, Writer}; use designcraft_color::swatch::{ColorType, SwatchValue}; use designcraft_color::{BlendMode as DcBlend, Color, GradientKind}; use designcraft_compose::Cache; @@ -10,10 +12,9 @@ use designcraft_doc::{AssetId, Content, Document, Item, PageSide, Spread, Stroke use designcraft_geom::kurbo::{PathEl, Shape as _}; use designcraft_geom::{Affine, BezPath, Rect, Vec2, corners}; use krilla::color::{cmyk, luma, rgb}; -use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion}; +use krilla::configure::{Archival, PdfVersion}; use krilla::geom::{Path, PathBuilder, Size, Transform}; use krilla::image::Image; -use krilla::metadata::Metadata; use krilla::num::NormalizedF32; use krilla::page::PageSettings; use krilla::paint::{Fill, FillRule, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash}; @@ -59,13 +60,13 @@ fn sheets(doc: &Document, opts: &PdfOptions) -> Result> { return Err(PdfError::NoPages); } if let Some(bad) = pages.iter().find(|p| **p >= count) { - return Err(PdfError::BadPage(bad + 1)); + return Err(PdfError::BadPage(bad.saturating_add(1))); } let b = doc.settings.bleed.map(|v| if opts.bleed { v.max(0.0) } else { 0.0 }); let mut out: Vec = Vec::new(); let mut seen_spreads: Vec = Vec::new(); for abs in pages { - let (si, pi) = doc.page_loc(abs).ok_or(PdfError::BadPage(abs + 1))?; + let (si, pi) = doc.page_loc(abs).ok_or(PdfError::BadPage(abs.saturating_add(1)))?; let sp: &Spread = &doc.spreads[si]; let (trim, l, r, label) = if opts.spreads { if seen_spreads.contains(&si) { @@ -101,6 +102,10 @@ pub(crate) fn mark_start(opts: &PdfOptions, bleed: [f64; 4]) -> f64 { /// Like [`export_pdf`], also returning warnings about approximated or dropped features. pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result { + write::guard(|| export_pdf_with_report_inner(doc, cache, opts)) +} + +fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result { let sheets = sheets(doc, opts)?; let warnings = Vec::new(); let (version, archival) = match opts.standard { @@ -108,37 +113,20 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) Standard::PdfX4 => (PdfVersion::Pdf16, None), Standard::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)), }; - let mut cb = ConfigurationBuilder::new().with_version(version); - if let Some(a) = archival { - cb = cb.with_archival_validator(a); - } - let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; + let configuration = write::configuration(version, archival)?; let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() }; - let mut pdf = krilla::Document::new_with(settings); - let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let mut meta = Metadata::new().creator("DesignCraft".into()).producer("DesignCraft".into()); - if !title.is_empty() { - meta = meta.title(title.clone()); - } - if let Some(a) = &opts.author { - meta = meta.authors(vec![a.clone()]); - } let created = opts.created.or_else(now_unix); - if let Some(t) = created { - let c = civil(t); - meta = meta.creation_date( - krilla::metadata::DateTime::new(c.0.clamp(0, 9999) as u16) - .month(c.1) - .day(c.2) - .hour(c.3) - .minute(c.4) - .second(c.5) - .utc_offset_hour(0) - .utc_offset_minute(0), - ); - } - pdf.set_metadata(meta); + let metadata = Metadata { + creator: "DesignCraft".into(), + producer: "DesignCraft".into(), + title: Some(title.clone()), + authors: opts.author.iter().cloned().collect(), + created: created.map(date_time), + ..Default::default() + }; + let mut writer = Writer::new(settings, metadata); + let pdf = writer.document_mut(); let mut ex = Exporter { doc, @@ -182,6 +170,8 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) let settings = PageSettings::new(size).with_trim_box(local(sh.trim)).with_bleed_box(local(sh.bleed)); let mut page = pdf.start_page_with(settings); let mut s = page.surface(); + #[cfg(all(test, not(target_arch = "wasm32")))] + unwind_tests::inject(&mut s); s.push_transform(&tf(Affine::translate(-sh.media.origin().to_vec2()))); ex.clip = sh.bleed; if let Some(clip) = to_path(&sh.bleed.to_path(0.1)) { @@ -207,7 +197,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) pdf.set_tag_tree(ex.tag_tree()); } ex.check_placed_versions()?; - let mut bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; + let mut bytes = writer.finish().map_err(PdfError::from)?; // Transparency blends in the document's blend space: CMYK for print (and always in PDF/X). let mut warnings = ex.warnings; if !ex.rgb_only && (doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk || opts.standard == Standard::PdfX4) { @@ -245,12 +235,13 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) /// One PDF from several (a book's documents), every page carried over as vectors; each part /// comes with its pages' sizes (points). pub fn merge_pdfs(parts: &[(Vec, Vec<(f32, f32)>)], title: Option<&str>) -> Result> { - let mut pdf = krilla::Document::new_with(krilla::SerializeSettings::default()); - let mut meta = Metadata::new().creator("DesignCraft".into()).producer("DesignCraft".into()); - if let Some(t) = title { - meta = meta.title(t.to_string()); - } - pdf.set_metadata(meta); + write::guard(|| merge_pdfs_inner(parts, title)) +} + +fn merge_pdfs_inner(parts: &[(Vec, Vec<(f32, f32)>)], title: Option<&str>) -> Result> { + let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: title.map(str::to_owned), ..Default::default() }; + let mut writer = Writer::new(krilla::SerializeSettings::default(), metadata); + let pdf = writer.document_mut(); let mut pages = 0; for (bytes, sizes) in parts { let src = krilla::pdf::Pdf::new(Arc::new(bytes.clone())).map_err(|e| PdfError::Write(format!("{e:?}")))?; @@ -259,6 +250,8 @@ pub fn merge_pdfs(parts: &[(Vec, Vec<(f32, f32)>)], title: Option<&str>) -> let size = Size::from_wh(w.max(1.0), h.max(1.0)).ok_or(PdfError::NoPages)?; let mut page = pdf.start_page_with(PageSettings::new(size)); let mut s = page.surface(); + #[cfg(all(test, not(target_arch = "wasm32")))] + unwind_tests::inject(&mut s); s.draw_pdf_page(&doc, size, i); s.finish(); page.finish(); @@ -268,7 +261,7 @@ pub fn merge_pdfs(parts: &[(Vec, Vec<(f32, f32)>)], title: Option<&str>) -> if pages == 0 { return Err(PdfError::NoPages); } - pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}"))) + writer.finish().map_err(PdfError::from) } /// File › Print Booklet: how pages pair up on printer spreads. @@ -303,19 +296,20 @@ pub fn booklet_pairs(n: usize, kind: BookletKind) -> Vec<(Option, Option< /// An imposed PDF: two document pages per sheet, in booklet order. pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> Result { + write::guard(|| export_booklet_inner(doc, cache, opts)) +} + +fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> Result { let n = doc.page_count(); if n == 0 { return Err(PdfError::NoPages); } let pdf_opts = PdfOptions { title: opts.title.clone(), ..PdfOptions::default() }; let settings = krilla::SerializeSettings { compress_content_streams: true, ..Default::default() }; - let mut pdf = krilla::Document::new_with(settings); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let mut meta = Metadata::new().creator("DesignCraft".into()).producer("DesignCraft".into()); - if !title.is_empty() { - meta = meta.title(title); - } - pdf.set_metadata(meta); + let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: Some(title), ..Default::default() }; + let mut writer = Writer::new(settings, metadata); + let pdf = writer.document_mut(); let mut ex = Exporter { doc, cache, @@ -355,6 +349,8 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R for (left, right) in &pairs { let mut page = pdf.start_page_with(PageSettings::new(size)); let mut s = page.surface(); + #[cfg(all(test, not(target_arch = "wasm32")))] + unwind_tests::inject(&mut s); for (slot, abs) in [(0.0, left), (pw + gap, right)] { let Some((si, pi)) = abs.and_then(|a| doc.page_loc(a)) else { continue }; let p = &doc.spreads[si].pages[pi]; @@ -374,7 +370,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R page.finish(); } ex.check_placed_versions()?; - let mut bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; + let mut bytes = writer.finish().map_err(PdfError::from)?; if doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk { crate::pdfx::cmyk_group_spaces(&mut bytes); } @@ -393,6 +389,18 @@ fn now_unix() -> Option { None } +fn date_time(t: i64) -> krilla::metadata::DateTime { + let c = civil(t); + krilla::metadata::DateTime::new(c.0.clamp(0, 9999) as u16) + .month(c.1) + .day(c.2) + .hour(c.3) + .minute(c.4) + .second(c.5) + .utc_offset_hour(0) + .utc_offset_minute(0) +} + /// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian. pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) { let days = t.div_euclid(86_400); @@ -1751,3 +1759,54 @@ mod image_content_cache_tests { assert!(duplicate.upgrade().is_none()); } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod unwind_tests { + use super::*; + use std::cell::Cell; + + thread_local! { + static FAIL_NEXT_SURFACE: Cell = const { Cell::new(false) }; + } + + // Fail with live backend drawing state, so a missing guard or an unpatched destructor + // makes the child fail. Thread-local state keeps unrelated export tests independent. + #[allow(clippy::panic)] // Test-only fault injection exercises recovery with a live surface. + pub(super) fn inject(surface: &mut krilla::surface::Surface<'_>) { + if FAIL_NEXT_SURFACE.replace(false) { + surface.push_transform(&krilla::geom::Transform::from_translate(2.0, 3.0)); + panic!("synthetic export drawing failure"); + } + } + + #[test] + fn export_entry_points_recover_in_a_subprocess() { + const CHILD: &str = "DESIGNCRAFT_PDF_EXPORT_PANIC_PROBE"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "export::unwind_tests::export_entry_points_recover_in_a_subprocess", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(output.status.success(), "export child failed: {:?}\n{}", output.status, String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("all three export entry points recovered")); + return; + } + let doc = Document::new(&designcraft_doc::build::NewDocument { pages: 1, ..Default::default() }); + let cache = Cache::new(); + let source = export_pdf(&doc, &cache, &Default::default()).unwrap(); + let parts = [(source, vec![(612.0, 792.0)])]; + for entry in ["document", "booklet", "merge"] { + let export = || match entry { + "document" => export_pdf(&doc, &cache, &Default::default()), + "booklet" => export_booklet(&doc, &cache, &Default::default()).map(|report| report.bytes), + _ => merge_pdfs(&parts, None), + }; + FAIL_NEXT_SURFACE.set(true); + assert!(matches!(export(), Err(PdfError::Write(message)) if message.contains("synthetic export drawing failure")), "{entry}"); + let fresh = export().unwrap(); + assert!(!hayro_syntax::Pdf::new(Arc::new(fresh)).unwrap().pages().is_empty(), "fresh {entry} export must still work"); + } + println!("all three export entry points recovered"); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index c4aad577c..d22098cb8 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -200,6 +200,12 @@ pub fn parse_page_range(s: &str, count: usize) -> Result> { Ok(out) } +impl From for PdfError { + fn from(error: craft_pdf::write::WriteError) -> Self { + Self::Write(error.to_string()) + } +} + #[cfg(test)] mod tests; diff --git a/crates/pdf/src/links.rs b/crates/pdf/src/links.rs index ce81287a1..07541ad1f 100644 --- a/crates/pdf/src/links.rs +++ b/crates/pdf/src/links.rs @@ -1,6 +1,7 @@ //! Interactive PDF: hyperlink annotations (frames and text ranges → URLs, e-mail, pages) and the //! bookmark outline. +use craft_pdf::write::backend as krilla; use designcraft_doc::{Bookmark, Document, HyperlinkDest, HyperlinkSource, SpreadRef}; use designcraft_geom::{Affine, Rect}; use krilla::action::{Action, LinkAction}; diff --git a/crates/pdf/src/marks.rs b/crates/pdf/src/marks.rs index a2dab3356..07894e06a 100644 --- a/crates/pdf/src/marks.rs +++ b/crates/pdf/src/marks.rs @@ -1,6 +1,7 @@ //! Printer's marks drawn in the slug area: crop marks at the trim corners, bleed marks at the bleed //! corners, and a page-information line. All marks use `[Registration]` (every plate). +use craft_pdf::write::backend as krilla; use designcraft_geom::{BezPath, Point}; use krilla::num::NormalizedF32; use krilla::paint::{LineCap, LineJoin, Stroke}; diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 3218ffc76..ebb3d12da 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -452,6 +452,7 @@ impl RgbScan { #[cfg(test)] mod tests { + use craft_pdf::write::backend as krilla; use std::sync::Arc; use designcraft_color::{Color, Swatch}; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d057..fdda4b28d 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -446,3 +446,26 @@ fn column_rules_are_drawn() { assert!((xs[0] - 214.0).abs() < 0.01 && (xs[1] - 398.0).abs() < 0.01, "{xs:?}"); assert!(rules(false).is_empty()); } + +#[test] +fn extreme_page_selection_is_a_page_error_without_overflow() { + let doc = doc_with_text("Bounded page selection"); + let opts = PdfOptions { pages: Some(vec![usize::MAX]), ..Default::default() }; + assert_eq!(export_pdf(&doc, &Cache::new(), &opts), Err(PdfError::BadPage(usize::MAX))); +} + +#[test] +fn empty_and_populated_export_metadata_preserve_key_presence() { + let mut d = Document::new(&NewDocument::default()); + d.title.clear(); + let s = uncompressed(&d, PdfOptions::default()); + for key in ["/Title", "/Author", "", ""] { + assert!(!s.contains(key), "unexpected empty metadata {key}"); + } + d.title = "Synthetic title".into(); + let s = uncompressed(&d, PdfOptions { author: Some("Test author".into()), created: Some(1_700_000_000), ..Default::default() }); + for key in ["/Title", "/Author", "/CreationDate", "", ""] { + assert!(s.contains(key), "missing populated metadata {key}"); + } + assert!(s.contains("Synthetic title") && s.contains("Test author")); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index bf59efded..ba06d48ab 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::ops::Range; use std::sync::Arc; +use craft_pdf::write::backend as krilla; use designcraft_compose::{ComposedStory, FrameText, PlacedGlyph}; use designcraft_fonts::{FontDb, FontFace}; use designcraft_geom::kurbo::Shape as _; @@ -66,13 +67,11 @@ impl Exporter<'_> { self.fonts .entry(face.id()) .or_insert_with(|| { - if face.is_variable() { - // A named instance: krilla embeds the font at these axis settings. - let coords: Vec<(krilla::text::Tag, f32)> = face.coords.iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect(); - Font::new_variable(face.data().to_vec().into(), face.index(), &coords) - } else { - Font::new(face.data().to_vec().into(), face.index()) - } + // Keep the existing outline fallback and warning when embedding fails. + let variations = face.is_variable().then_some(face.coords.as_slice()); + craft_pdf::write::font(face.data(), face.index(), variations) + .inspect_err(|error| log::warn!("PDF font `{} {}` could not be embedded: {error}", face.family, face.style)) + .ok() }) .clone() } diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs index 6a4ac91f1..c7812ce7b 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -3,6 +3,8 @@ //! All images and placed PDFs are tiny procedural fixtures. use std::sync::Arc; +use craft_pdf::write::backend as krilla; + use designcraft_compose::Cache; use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef}; use designcraft_geom::{Affine, Rect, shapes}; diff --git a/docs/architecture.md b/docs/architecture.md index 39a9039dc..5073a4b86 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DesignCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (shared PDF writing) · **Target:** Adobe InDesign 2026 How DesignCraft is built today. The design notes behind it are in `plan/architecture.md` (local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the @@ -21,7 +21,7 @@ lines of Rust, 1,122 tests (2026-10-10). Pure Rust, no C dependencies by default | L2 | `compose` | the text engine: style resolution, shaping, Knuth–Plass and single-line breaking, Liang hyphenation, justification, tabs, keeps, columns, span/split, baseline grid, vertical justification, wrap, threading, overset, anchored objects, footnotes, tables, bidi, CJK (ruby, warichu, kinsoku), caret and hit testing | 11.7k | | L2 | `images` | decoding placed graphics (PNG, JPEG, GIF, WebP, TIFF incl. CMYK, BMP, PSD composite, EPS preview) | 1.6k | | L3 | `render` | document to pixels with vello_cpu (SIMD, multithreaded): items, composed text, images with mips, gradients, effects, damage regions, placed PDF via hayro | 3.5k | -| L3 | `pdf` | PDF export with krilla: real text with subset fonts, CMYK and spot, bleed and marks, tagged PDF, links, forms, transitions, PDF/X-4 and PDF/A-2b | 5.0k | +| L3 | `pdf` | PDF export through craft-pdf with Krilla: real text with subset fonts, CMYK and spot, bleed and marks, tagged PDF, links, forms, transitions, PDF/X-4 and PDF/A-2b | 5.0k | | L3 | `idml` | IDML import and export (designmap, spreads, parents, stories, styles, graphics, preferences; CJK and Arabic attributes) | 8.0k | | L3 | `epub` | EPUB 3 reflowable and fixed-layout export | 0.9k | | L3 | `textimport` | Word (.docx), RTF, Excel (.xlsx), Tagged Text import; text export | 1.8k | @@ -83,6 +83,12 @@ Native `.designcraft` (zip + JSON); IDML both ways; PDF, EPUB, HTML, PNG/JPEG, t placed graphics through `images` (PDF/AI pages through hayro); Word/RTF/Excel/Tagged Text through `textimport`. INDD is not readable. Details: [file-format-parity.md](file-format-parity.md). +PDF authoring uses the independently pinned `craft-pdf` writer and patched Krilla. Document, +booklet and merge entry points guard the full export; a failed native drawing unwind returns +an error and incomplete backend state cannot serialize. Layout, shaping, PDF/X processing and +print preparation remain app-owned. Reader dependencies and the UI/print pins are unchanged. +See [pdf-backend.md](pdf-backend.md) for dependency and failure contracts. + ## Agent control The desktop app opens a loopback JSON-lines control channel (`--control PORT`) with diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md new file mode 100644 index 000000000..fcc6afeaa --- /dev/null +++ b/docs/pdf-backend.md @@ -0,0 +1,34 @@ +# PDF writing boundary + +DesignCraft pins `craft-pdf` and its patched Krilla 0.8.2 backend to craft-libs revision +`c3487320b2d1de657b860b3c8abb44e1f2b32bd8`. The `write-pdf` capability is required for +placed PDF pages and book merging. The ordinary reader dependencies, `craft-ui` and +`craft-print` revisions remain unchanged by this writer migration. + +The shared library supplies fallible font copying/preparation, metadata setup, version and +archival configuration, final serialization and a native unwind guard. DesignCraft retains +layout, shaping, font choice/licensing, fallback warnings, image caches, links, tagging, +bleed/marks, PDF/X postprocessing and export presets. A shared writer does not improve the +format-conformance or product-parity claims by itself. + +`export_pdf_with_report`, `export_booklet` and `merge_pdfs` guard the whole export, including +painting and postprocessing. A native drawing panic becomes `PdfError::Write`. A failed +page or surface cleanup permanently poisons the backend writer, preventing successful +serialization of an incomplete document after a narrow catch. Discard other captured app +state after an internal failure. These guards do not catch WASM aborts, allocation aborts, +or errors in preceding application operations such as print preparation. + +Cargo applies patches only from the consuming workspace root. Keep the Krilla patch and +facade at the same full immutable commit, including Krilla reached through `krilla-svg`. +`cargo xtask pdf-deps` verifies their resolved versions, source and uniqueness, and runs in +`cargo xtask ci`. Publish the dependency revision before publishing this consumer branch. +Local path replacements are suitable only for development and should not be committed. + +The vendored Krilla patch ledger, per-file hashes, licence texts and four unchanged CC0 +Compact ICC profiles live in craft-libs (`vendor/krilla`, `licenses/pdf-writer`, and +`ATTRIBUTION.md`). No fonts or binary PDF fixtures are added to DesignCraft by this migration. + +Tests cover metadata key presence, hostile page selection, successful PDF export and native +subprocess recovery for all three export entry points. Native/WASM export comparisons and +independent PDF parsing/rendering supplement the repository gates. Physical printer output, +Windows/Linux runtime and PDF/A/PDF/X certification remain separate acceptance work. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 4850efeae..c89b7e8d7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -7,6 +7,7 @@ mod assets; mod ico; mod layers; mod parity; +mod pdf_deps; mod stats; mod version; @@ -24,6 +25,7 @@ commands: corpus [--download] show where test corpora live; --download fetches PngSuite into corpus/pngsuite stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + pdf-deps verify the pinned shared PDF writer and patched Krilla parity recompute the InDesign feature-parity summary in docs/parity-checklist.md ico ... pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) @@ -39,6 +41,7 @@ fn main() -> ExitCode { Some("ci") => cmd_ci(), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("pdf-deps") => pdf_deps::run(&root()), Some("parity") => parity::run(&root()), Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), @@ -175,6 +178,7 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo test --workspace") }), ), + ("pdf-deps", Box::new(|| pdf_deps::run(&root()))), ("assets", Box::new(|| assets::run(&root()))), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs new file mode 100644 index 000000000..d7dfb1cb7 --- /dev/null +++ b/xtask/src/pdf_deps.rs @@ -0,0 +1,87 @@ +//! Check the shared writer source, including transitive users such as krilla-svg. +use std::path::Path; +use std::process::Command; + +use serde_json::Value; + +const REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8"; +const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("krilla", "0.8.2")]; + +pub fn run(root: &Path) -> Result<(), String> { + let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(root) + .args(["metadata", "--locked", "--format-version", "1"]) + .output() + .map_err(|e| format!("cargo metadata: {e}"))?; + if !output.status.success() { + return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr))); + } + let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?; + validate(&metadata)?; + println!("PDF writer: facade and patched Krilla resolve once to {REV}"); + Ok(()) +} + +fn validate(metadata: &Value) -> Result<(), String> { + let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?; + let source = format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}"); + for (name, version) in PACKAGES { + let matching: Vec<_> = packages.iter().filter(|p| p.get("name").and_then(Value::as_str) == Some(name)).collect(); + let [package] = matching.as_slice() else { + return Err(format!("{name}: expected one resolved package, found {}", matching.len())); + }; + if package.get("version").and_then(Value::as_str) != Some(version) || package.get("source").and_then(Value::as_str) != Some(source.as_str()) { + return Err(format!("{name}: expected {version} from {source}; got {package}")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn metadata() -> Value { + json!({"packages": PACKAGES.iter().map(|(name, version)| json!({"name":name,"version":version, + "source":format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")})).collect::>()}) + } + + #[test] + fn accepts_one_pinned_facade_and_writer() { + assert!(validate(&metadata()).is_ok()); + } + + #[test] + fn rejects_unpatched_and_local_writer_sources() { + for source in [Value::Null, json!("registry+https://github.com/rust-lang/crates.io-index")] { + let mut m = metadata(); + m["packages"][1]["source"] = source; + assert!(validate(&m).is_err()); + } + } + + #[test] + fn rejects_missing_duplicate_and_wrong_version_packages() { + for index in [0, 1] { + let mut m = metadata(); + m["packages"][index]["version"] = json!("99.0.0"); + assert!(validate(&m).is_err()); + let mut m = metadata(); + let duplicate = m["packages"][index].clone(); + m["packages"].as_array_mut().unwrap().push(duplicate); + assert!(validate(&m).is_err()); + let mut m = metadata(); + m["packages"].as_array_mut().unwrap().remove(index); + assert!(validate(&m).is_err()); + } + } + + #[test] + fn rejects_facade_revision_drift_and_malformed_metadata() { + let mut m = metadata(); + m["packages"][0]["source"] = json!("git+https://github.com/storytold/craft-libs?branch=main#other"); + assert!(validate(&m).is_err()); + assert!(validate(&json!({})).is_err()); + } +} From 4760a000dbcf42515a915c6c1ef2ec542f111a87 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:16:28 +1000 Subject: [PATCH 031/128] PARITY-FIND-SCOPE: respect search scope and first-match limits Co-Authored-By: Codex --- crates/engine/src/cmd/find.rs | 921 ++++++++++++++++- crates/engine/src/cmd/mod.rs | 3 +- crates/engine/src/cmd/text.rs | 2 + crates/engine/src/lib.rs | 20 + crates/ui-egui/src/control.rs | 8 +- crates/ui-egui/src/dialogs.rs | 1837 ++++++++++++++++++++++++--------- crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/menus.rs | 18 + 8 files changed, 2280 insertions(+), 539 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 5dc0bc8ad..39f85b0a5 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -1,17 +1,22 @@ //! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters, //! optional formatting in the change, scoped to the document, a story or the selection. -use designcraft_doc::{StoryId, TextSel, story}; +use std::ops::Range; +use std::sync::{Arc, Weak}; + +use designcraft_doc::story::floor_char_boundary; +use designcraft_doc::{Document, Selection, StoryId, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; -use crate::{Result, Session}; +use crate::{DocState, Result, Session}; pub fn specs() -> Vec { vec![ cmd!(noundo "find.objects", "Find Object", [], None, "{fill?, stroke?: swatch, strokeWeight?, opacity? (0–1), kind?: text|graphic|shape|line|group, layer?, label?, select?: bool (default true)} → {ids} — Find/Change › Object, through the document", has_doc, |s, p| { + s.reset_find_scope(); let ids = find_objects(&s.doc()?.doc, p); if p.get("select").and_then(Value::as_bool).unwrap_or(true) && !ids.is_empty() { let st = s.doc_mut()?; @@ -28,6 +33,7 @@ pub fn specs() -> Vec { "{…find.objects criteria, change: {fill?, stroke?, strokeWeight?, opacity?}} → {changed}", has_doc, |s, p| { + s.reset_find_scope(); let ids = find_objects(&s.doc()?.doc, p); let ch = p.get("change").cloned().unwrap_or(Value::Null); if !ch.is_object() { @@ -65,7 +71,7 @@ pub fn specs() -> Vec { has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, …} — selects the next match after the caret", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -125,34 +131,182 @@ fn build(p: &Value) -> Result { .map_err(|e| bad("find", format!("bad GREP: {e}"))) } -fn scope(s: &Session, p: &Value) -> Result)>> { +#[derive(Clone, Debug, PartialEq, Eq)] +enum Domain { + Document, + Story, + Selection, + Explicit(StoryId), +} + +impl Domain { + fn from_params(p: &Value) -> Self { + if let Some(id) = p.get("story").and_then(Value::as_u64) { + return Self::Explicit(StoryId(id)); + } + match str_param(p, "scope").unwrap_or("document") { + "story" => Self::Story, + "selection" => Self::Selection, + _ => Self::Document, + } + } +} + +/// Whether two requests use the same search domain, including explicit-story precedence. +pub fn same_find_domain(a: &Value, b: &Value) -> bool { + Domain::from_params(a) == Domain::from_params(b) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Matcher { + find: String, + grep: bool, + case_sensitive: bool, + whole_word: bool, +} + +impl Matcher { + fn from_params(p: &Value) -> Self { + Self { + find: str_param(p, "find").unwrap_or("").to_string(), + grep: bool_or(p, "grep", false), + case_sensitive: bool_or(p, "caseSensitive", false), + whole_word: bool_or(p, "wholeWord", false), + } + } +} + +/// A search owns its original domain, not the selection used to paint the latest hit. +/// Weak identity avoids retaining an old document merely for this transient cache. +#[derive(Clone)] +pub(crate) struct FindContext { + document: Weak, + uid: u64, + revision: u64, + selection: Selection, + domain: Domain, + matcher: Matcher, + targets: Vec<(StoryId, Range)>, + order: Vec, + target: usize, + offset: usize, + last_match_end: Option, +} + +impl FindContext { + pub(crate) fn is_current(&self, st: &DocState) -> bool { + self.uid == st.uid && Weak::ptr_eq(&self.document, &Arc::downgrade(&st.doc)) && self.revision == st.revision && self.selection == st.selection + } + + fn acknowledge(&mut self, st: &DocState) { + self.revision = st.revision; + self.selection = st.selection.clone(); + } + + fn restart(&mut self, st: &DocState) -> Result<()> { + self.target = 0; + self.last_match_end = None; + self.order = (0..self.targets.len()).collect(); + let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none()); + if self.domain == Domain::Document + && let Some(t) = st.selection.text + && let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story) + { + self.order.rotate_left(i); + } + let first = self.order.first().and_then(|i| self.targets.get(*i)); + self.offset = match first { + Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)), + Some((_, r)) => r.start, + None => 0, + }; + if let Some((sid, r)) = first { + let text = target_text(&st.doc, *sid, r)?; + if text.get(self.offset.saturating_sub(r.start)..).is_none() { + return Err(bad("find", "invalid UTF-8 search cursor")); + } + } + Ok(()) + } + + fn advance_target(&mut self) { + self.target = self.target.saturating_add(1); + self.last_match_end = None; + self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start); + } + + fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) { + // Move one Unicode scalar, not one byte; an endpoint is returned at most once. + if let Some(ch) = text.get(relative_end..).and_then(|rest| rest.chars().next()) { + self.offset = end + ch.len_utf8(); + } else { + self.advance_target(); + } + } +} + +fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range) -> Result<&'a str> { + d.story(sid) + .ok_or_else(|| bad("find", "no such story"))? + .text + .get(r.clone()) + .ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary")) +} + +fn scope(s: &Session, p: &Value) -> Result { let st = s.doc()?; + let domain = Domain::from_params(p); + let matcher = Matcher::from_params(p); + if let Some(previous) = s.find_context.as_ref().filter(|c| c.is_current(st) && c.domain == domain) { + let mut context = previous.clone(); + if context.matcher != matcher { + context.matcher = matcher; + context.restart(st)?; + } + return Ok(context); + } let d = &st.doc; - if let Some(sid) = p.get("story").and_then(Value::as_u64) { - let sid = StoryId(sid); - let len = d.story(sid).map(|x| x.len()).ok_or_else(|| bad("find", "no such story"))?; - return Ok(vec![(sid, 0..len)]); - } - match str_param(p, "scope").unwrap_or("document") { - "story" => { - let t = st.selection.text.ok_or_else(|| bad("find", "no story selected"))?; - Ok(vec![(t.story, 0..d.story(t.story).map(|x| x.len()).unwrap_or(0))]) + let targets = match domain { + Domain::Explicit(sid) => { + let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len(); + vec![(sid, 0..len)] } - "selection" => { + Domain::Story | Domain::Selection => { + if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { + return Err(bad("find", "Story/Selection search in table cells or notes is not supported")); + } let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?; - Ok(vec![(t.story, t.range())]) + let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?; + let r = if domain == Domain::Selection { t.range() } else { 0..story.len() }; + target_text(d, t.story, &r)?; + vec![(t.story, r)] } - _ => Ok(d.stories.values().map(|x| (x.id, 0..x.len())).collect()), - } + Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(), + }; + let mut context = FindContext { + document: Arc::downgrade(d), + uid: st.uid, + revision: st.revision, + selection: st.selection.clone(), + domain, + matcher, + targets, + order: Vec::new(), + target: 0, + offset: 0, + last_match_end: None, + }; + context.restart(st)?; + Ok(context) } fn find(s: &mut Session, p: &Value) -> Result { let re = build(p)?; + let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); - for (sid, r) in scope(s, p)? { - let Some(story) = st.doc.story(sid) else { continue }; - let text = &story.text[r.clone()]; + for (sid, r) in &context.targets { + let text = target_text(&st.doc, *sid, r)?; for m in re.find_iter(text) { out.push(json!({"story": sid.0, "start": r.start + m.start(), "end": r.start + m.end(), "text": m.as_str()})); if out.len() >= 10_000 { @@ -160,6 +314,7 @@ fn find(s: &mut Session, p: &Value) -> Result { } } } + s.find_context = Some(context); Ok(Value::Array(out)) } @@ -182,13 +337,13 @@ fn change(s: &mut Session, p: &Value) -> Result { } } super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; - let targets = scope(s, p)?; + let targets = scope(s, p)?.targets; s.edit(|d, sel| { let mut count = 0usize; for (sid, r) in targets { let Some(story) = d.story(sid) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. - let text = story.text[r.clone()].to_string(); + let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?.to_string(); let mut edits: Vec<(std::ops::Range, String)> = Vec::new(); for caps in re.captures_iter(&text) { let Some(m) = caps.get(0) else { continue }; @@ -211,10 +366,21 @@ fn change(s: &mut Session, p: &Value) -> Result { } count += 1; } - if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { - let len = st.len(); - t.anchor = t.anchor.min(len); - t.focus = t.focus.min(len); + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) { + // Repair byte validity once against the final story, without remapping valid offsets. + let range = t.range(); + let start = floor_char_boundary(&st.text, range.start); + let mut end = range.end.min(st.len()); + if t.is_caret() { + end = start; + } else if !st.text.is_char_boundary(end) { + let floor = floor_char_boundary(&st.text, end); + end = st.text.get(floor..).and_then(|rest| rest.chars().next()).map_or(floor, |ch| floor + ch.len_utf8()); + } + (t.anchor, t.focus) = if t.anchor <= t.focus { (start, end) } else { (end, start) }; + } + if first_only && count > 0 { + break; } } Ok(json!({"count": count})) @@ -223,29 +389,44 @@ fn change(s: &mut Session, p: &Value) -> Result { fn find_next(s: &mut Session, p: &Value) -> Result { let re = build(p)?; + let mut context = scope(s, p)?; let st = s.doc()?; - let (start_story, start_pos) = st.selection.text.map(|t| (Some(t.story), t.range().end)).unwrap_or((None, 0)); - let mut order: Vec = st.doc.stories.keys().copied().collect(); - if let Some(ss) = start_story - && let Some(i) = order.iter().position(|x| *x == ss) - { - order.rotate_left(i); - } let mut hit = None; - for (k, sid) in order.iter().enumerate() { - let Some(story) = st.doc.story(*sid) else { continue }; - let from = if k == 0 && start_story == Some(*sid) { start_pos.min(story.len()) } else { 0 }; - if let Some(m) = re.find_at(&story.text, from) { - hit = Some((*sid, m.start(), m.end())); + while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() { + let text = target_text(&st.doc, sid, &r)?; + let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?; + if text.get(from..).is_none() { + return Err(bad("find", "invalid UTF-8 search cursor")); + } + if let Some(m) = re.find_at(text, from) { + let (a, b) = (r.start + m.start(), r.start + m.end()); + // Match find_iter/captures_iter: suppress an empty match adjacent to the + // previous match's end, but keep adjacent nonempty matches. + if m.is_empty() && context.last_match_end == Some(b) { + context.advance_empty(text, m.end(), b); + continue; + } + hit = Some((sid, a, b)); + context.last_match_end = Some(b); + if m.is_empty() { + context.advance_empty(text, m.end(), b); + } else { + context.offset = b; + } break; } + context.advance_target(); } - let Some((sid, a, b)) = hit else { return Ok(Value::Null) }; + let Some((sid, a, b)) = hit else { + s.find_context = Some(context); + return Ok(Value::Null); + }; let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied()); let stm = s.doc_mut()?; - stm.selection = designcraft_doc::Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None }); + stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None }); stm.revision += 1; - let _ = story::PAGE_NUMBER; + context.acknowledge(stm); + s.find_context = Some(context); Ok(json!({"story": sid.0, "start": a, "end": b})) } @@ -278,6 +459,664 @@ mod tests { s.execute("find.next", &json!({"find": "or"})).unwrap(); assert!(s.doc().unwrap().selection.text.is_some()); } + fn scope_session(text: &str) -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); + (s, designcraft_doc::StoryId(frame["story"].as_u64().unwrap())) + } + + fn scope_select(s: &mut Session, sid: designcraft_doc::StoryId, a: usize, b: usize) { + s.execute("text.select", &json!({"story": sid.0, "anchor": a, "focus": b})).unwrap(); + } + + fn scope_hit(s: &mut Session, p: &serde_json::Value, sid: designcraft_doc::StoryId, a: usize, b: usize) { + assert_eq!(s.execute("find.next", p).unwrap(), json!({"story": sid.0, "start": a, "end": b})); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.range(), selected.cell), (sid, a..b, None)); + } + + #[test] + fn find_scope_first_change_stops_after_first_matching_story_and_repairs_selection() { + for (texts, first, explicit, grep, expected, count) in [ + (vec!["red red", "red"], Some(true), false, false, vec!["blue red", "red"], 1), + (vec!["keep", "red red", "red"], Some(true), false, false, vec!["keep", "blue red", "red"], 1), + (vec!["keep", "other"], Some(true), false, false, vec!["keep", "other"], 0), + (vec!["red red", "red"], Some(false), false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], None, false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], Some(true), true, false, vec!["blue red", "red"], 1), + (vec!["red red", "red"], Some(false), true, false, vec!["blue blue", "red"], 2), + (vec!["", "é🙂"], Some(true), false, true, vec!["X", "é🙂"], 1), + (vec!["é", "🙂"], Some(false), false, true, vec!["XéX", "X🙂X"], 4), + ] { + let (mut s, sid) = scope_session(texts[0]); + let mut sids = vec![sid]; + for text in texts.iter().skip(1) { + let frame = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": text})).unwrap(); + sids.push(designcraft_doc::StoryId(frame["story"].as_u64().unwrap())); + } + assert_eq!(s.doc().unwrap().doc.stories.keys().copied().collect::>(), sids); + // Document Change keeps canonical target order even with a later story current. + scope_select(&mut s, *sids.last().unwrap(), 0, 0); + let before = s.doc().unwrap().doc.clone(); + let mut p = json!({ + "find": if grep { "(?:)" } else { "red" }, + "change": if grep { "X" } else { "blue" }, + "scope": "document", "grep": grep + }); + if let Some(first) = first { + p["first"] = json!(first); + } + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + if first == Some(true) && !explicit && !grep && texts.last() == Some(&"red") { + let last = *sids.last().unwrap(); + scope_hit(&mut s, &p, last, 0, 3); + let first_hit = sids[if texts.len() == 2 { 0 } else { 1 }]; + assert_eq!( + s.execute("find.find", &p).unwrap(), + json!([ + {"story": first_hit.0, "start": 0, "end": 3, "text": "red"}, + {"story": first_hit.0, "start": 4, "end": 7, "text": "red"}, + {"story": last.0, "start": 0, "end": 3, "text": "red"} + ]), + "Count remains canonical after Next paints the later story" + ); + } + let selection = s.doc().unwrap().selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.selection, selection); + for (i, sid) in sids.iter().enumerate() { + assert_eq!(st.doc.story(*sid).unwrap().text, expected[i]); + if texts[i] == expected[i] { + assert_eq!(st.doc.story(*sid), before.story(*sid), "untouched story {i} stays entirely unchanged"); + } + } + st.doc.check().unwrap(); + } + + // Identical replacement is still one counted match, not a reason to visit another story. + let (mut s, sid) = scope_session("red red"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "red"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, other_sid, 0, 3); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + assert_eq!( + s.execute("find.change", &json!({"find": "red", "change": "red", "scope": "document", "first": true})).unwrap(), + json!({"count": 1}) + ); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red"); + assert_eq!(s.doc().unwrap().doc.story(other_sid), before.story(other_sid)); + assert_eq!(s.doc().unwrap().selection, selection); + s.doc().unwrap().doc.check().unwrap(); + + for reverse in [false, true] { + let (mut s, sid) = scope_session("éX X"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "X"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, if reverse { 3 } else { 2 }, if reverse { 2 } else { 3 }); + let before = s.doc().unwrap().doc.clone(); + let mut selection = s.doc().unwrap().selection.clone(); + let t = selection.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (6, 2) } else { (2, 6) }; + let p = json!({"find": "X", "change": "🙂", "scope": "document", "first": true}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, "é🙂 X"); + assert_eq!(st.doc.story(other_sid), before.story(other_sid)); + assert_eq!(st.selection, selection, "first-match exit must follow final UTF-8 repair"); + st.doc.check().unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "🙂", "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": 2, "end": 6, "text": "🙂"}]) + ); + } + } + + #[test] + fn find_scope_exact_baseline_count_next_change() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s + .execute( + "frame.create", + &json!({ + "rect": [36, 36, 300, 200], "content": "text", "text": "red red" + }), + ) + .unwrap(); + let sid = frame["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "red", "scope": "selection"})).unwrap(), + json!([{"story": sid, "start": 0, "end": 3, "text": "red"}]) + ); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "selection"})).unwrap(), json!({"story": sid, "start": 0, "end": 3})); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "scope": "selection"})).unwrap(), json!({"count": 1})); + assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "blue red"); + } + + #[test] + fn find_scope_change_growth_preserves_direction_and_full_scalar_hits() { + for (text, find, change, changed, start, old_end, new_end) in [("a", "a", "é", "é", 0, 1, 2), ("éX", "X", "🙂", "é🙂", 2, 3, 6)] { + for reverse in [false, true] { + let (mut s, sid) = scope_session(text); + // Real caret movement keeps the frame identity, unlike text.select. + s.execute("text.move", &json!({"dir": "left", "extend": reverse})).unwrap(); + if !reverse { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.anchor, selected.focus), if reverse { (old_end, start) } else { (start, old_end) }); + assert!(selected.frame.is_some()); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (new_end, start) } else { (start, new_end) }; + let p = json!({"find": find, "change": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": old_end, "text": find}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed); + assert_eq!(st.selection, expected); + let t = st.selection.text.unwrap(); + assert!(changed.is_char_boundary(t.anchor) && changed.is_char_boundary(t.focus)); + assert!(s.find_context.is_none()); + let p = json!({"find": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": new_end, "text": change}])); + scope_hit(&mut s, &p, sid, start, new_end); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } + } + } + + #[test] + fn find_scope_change_repairs_final_boundaries_without_remapping_valid_offsets() { + for (text, (a, b), find, change, changed, (after_a, after_b), count) in [ + ("aX", (1, 1), "a", "é", "éX", (0, 0), 1), // A caret stays collapsed and floors. + ("aX", (2, 2), "a", "é", "éX", (2, 2), 1), // A valid caret stays at its byte offset. + ("abc", (1, 2), "abc", "🙂", "🙂", (0, 4), 1), // Both endpoints need repair. + ("abc", (2, 1), "abc", "🙂", "🙂", (4, 0), 1), + ("é🙂", (2, 6), "🙂", "X", "éX", (2, 3), 1), // Existing shortening behavior. + ("é🙂", (6, 2), "🙂", "X", "éX", (3, 2), 1), + ("red", (0, 3), "red", "blue", "blue", (0, 3), 1), // Do not extend a valid upper bound. + ("aX", (1, 2), "a", "zz", "zzX", (1, 2), 1), // Do not track the original X. + ("aaX", (1, 2), "a", "é", "ééX", (0, 2), 2), // Per-edit repair would wrongly yield 0..4. + ("aaX", (2, 3), "a", "é", "ééX", (2, 4), 2), + ("abc", (1, 3), "abc", "", "", (0, 0), 1), + ("abc", (2, 3), "abc", "é", "é", (2, 2), 1), // EOF clamp may collapse a nonempty range. + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, a, b); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = (after_a, after_b); + assert_eq!(s.execute("find.change", &json!({"find": find, "change": change, "scope": "document"})).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed, "{text} {a}->{b}"); + assert_eq!(st.selection, expected, "{text} {a}->{b}"); + assert!(changed.is_char_boundary(after_a) && changed.is_char_boundary(after_b)); + if after_a == after_b { + assert_eq!( + s.execute("find.find", &json!({"find": "(?:)", "grep": true, "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": after_a, "end": after_a, "text": ""}]) + ); + } + } + } + + #[test] + fn find_scope_change_other_story_and_no_hits_preserve_selection() { + for explicit in [false, true] { + let (mut s, sid) = scope_session("éX"); + let frame = s.doc().unwrap().selection.text.unwrap().frame.unwrap(); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "a"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, 3, 2); + let before = s.doc().unwrap().selection.clone(); + let mut p = json!({"find": "a", "change": "🙂", "scope": "document"}); + if explicit { + p["story"] = json!(other_sid.0); + } + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 0})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + + s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); + let before = s.doc().unwrap().selection.clone(); + assert!(before.text.is_none()); + p["find"] = json!("🙂"); + p["change"] = json!("é"); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "é"); + } + } + + #[test] + fn find_scope_mixed_grep_matches_count_next_and_change() { + for (text, pattern, expected, changed) in [ + ("a", "a*", vec![(0, 1)], "X"), + ("aa", "a|(?:)", vec![(0, 1), (1, 2)], "XX"), + ("ab", "a*", vec![(0, 1), (2, 2)], "XbX"), + ("éa", "é|(?:)", vec![(0, 2), (3, 3)], "XaX"), + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, 0, text.len()); + let p = json!({"find": pattern, "grep": true, "scope": "selection", "change": "X"}); + let counted = s.execute("find.find", &p).unwrap(); + let ranges: Vec<_> = + counted.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap() as usize, m["end"].as_u64().unwrap() as usize)).collect(); + assert_eq!(ranges, expected, "{pattern} on {text}"); + for (a, b) in &expected { + scope_hit(&mut s, &p, sid, *a, *b); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "{pattern} on {text}"); + } + assert_eq!(s.execute("find.find", &p).unwrap(), counted); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], expected.len()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, changed); + } + } + + #[test] + fn find_scope_capture_replacement_and_first_keep_original_domain() { + let (mut s, sid) = scope_session("a1 a2 | a3"); + scope_select(&mut s, sid, 0, 5); + let p = json!({"find": r"a(\d)", "grep": true, "scope": "selection", "change": "b$1", "first": true}); + scope_hit(&mut s, &p, sid, 0, 2); + scope_hit(&mut s, &p, sid, 3, 5); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "b1 a2 | a3"); + } + + #[test] + fn find_scope_selection_retains_nonempty_hits_count_change_and_native_text() { + for (a, b) in [(0, 7), (7, 0)] { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, a, b); + let p = json!({"find": "red", "change": "blue", "scope": "selection", "attrs": {"fontStyle": "Italic"}}); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + let strong = std::sync::Arc::strong_count(&before); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(std::sync::Arc::strong_count(&before), strong, "search stores weak document identity"); + scope_hit(&mut s, &p, sid, 0, 3); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 2); + let changed = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(changed.text, "blue blue | red"); + assert_eq!(changed.char_format_at(0).over.font_style.as_deref(), Some("Italic")); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + assert!(s.find_context.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.find_context.is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap(), &changed); + let native = s.execute("file.serialize", &json!({})).unwrap(); + s.execute("file.openBytes", &json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(sid).unwrap()).unwrap(), serde_json::to_value(&changed).unwrap()); + assert!(s.find_context.is_none()); + } + } + + #[test] + fn find_scope_matcher_restart_replacement_continuation_and_external_reselection() { + let (mut s, sid) = scope_session("red blue red | blue red"); + scope_select(&mut s, sid, 0, 12); + let mut p = json!({"find": "red", "scope": "selection", "change": "one"}); + scope_hit(&mut s, &p, sid, 0, 3); + p["change"] = json!("two"); + p["attrs"] = json!({"fontStyle": "Italic"}); + scope_hit(&mut s, &p, sid, 9, 12); + p["find"] = json!("blue"); + scope_hit(&mut s, &p, sid, 4, 8); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 0, 3); + p["caseSensitive"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + p["grep"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); // GREP alone changes matcher identity. + p["find"] = json!("r.d"); + scope_hit(&mut s, &p, sid, 0, 3); + p["wholeWord"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + let hit = s.doc().unwrap().selection.text.unwrap(); + scope_select(&mut s, sid, hit.anchor, hit.focus); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1, "same-range external reselection starts a new domain"); + scope_hit(&mut s, &p, sid, 0, 3); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 20, 23); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 20, 23); + } + + #[test] + fn find_scope_word_reselection_at_same_hit_recaptures_bounds() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + let frame = selected.text.unwrap().frame.unwrap(); + let point = { + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, sid, None); + let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); + let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); + assert_eq!(cs.frames[fi].frame, frame); + assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1), "real point lies inside the current hit"); + let loc = st.doc.find(frame).unwrap(); + st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner + }; + let before = s.doc().unwrap().doc.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + s.execute("text.selectWord", &json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); + assert!(s.text_drag, "exercise place's in-selection early return"); + assert_eq!(s.doc().unwrap().selection, selected, "same full selection, including frame"); + assert_eq!(s.doc().unwrap().revision, revision + 1); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + + #[test] + fn find_scope_story_explicit_story_document_order_and_scope_changes() { + let (mut s, a) = scope_session("black red"); + let other = s.execute("frame.create", &json!({"rect": [150, 36, 100, 100], "content": "text", "text": "red"})).unwrap(); + let b = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, a, 0, 9); + let story = json!({"find": "red", "scope": "story"}); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "Story does not enter B or wrap to its prefix"); + scope_select(&mut s, b, 0, 0); + let explicit = json!({"find": "black", "story": a.0, "scope": "selection"}); + scope_hit(&mut s, &explicit, a, 0, 5); + let selection = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "red", "story": u64::MAX})).is_err()); + assert_eq!(s.doc().unwrap().selection, selection); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red"}), b, 0, 3); + let counted = s.execute("find.find", &json!({"find": "red"})).unwrap(); + assert_eq!(counted[0]["story"], a.0, "Count retains canonical story order"); + assert_eq!(counted[1]["story"], b.0); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red", "scope": "selection"}), a, 6, 9); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "scope change uses the current caret"); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + for command in ["find.find", "find.next", "find.change"] { + assert!(s.execute(command, &json!({"find": "red", "change": "blue", "scope": "selection"})).is_err()); + } + } + + #[test] + fn find_scope_substring_context_utf8_zero_width_and_invalid_ranges() { + let (mut s, sid) = scope_session("scat é🙂 red"); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "cat", "wholeWord": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 4); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "^c|^a", "grep": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 2); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "cursor does not redefine ^"); + scope_select(&mut s, sid, 5, 11); + let empty = json!({"find": "(?:)", "grep": true, "scope": "selection"}); + for pos in [5, 7, 11] { + scope_hit(&mut s, &empty, sid, pos, pos); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + } + assert_eq!(s.execute("find.find", &empty).unwrap().as_array().unwrap().len(), 3); + scope_select(&mut s, sid, 7, 7); + scope_hit(&mut s, &empty, sid, 7, 7); + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 12, 15); + let nonempty = json!({"find": "red", "change": "blue", "scope": "selection"}); + scope_hit(&mut s, &nonempty, sid, 12, 15); + assert_eq!(s.execute("find.change", &nonempty).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "scat é🙂 blue"); + for range in [6..7, 0..usize::MAX] { + let st = s.doc_mut().unwrap(); + st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { + story: sid, + anchor: range.start, + focus: range.end, + frame: None, + cell: None, + }); + st.revision += 1; + for command in ["find.find", "find.next", "find.change"] { + let before = s.doc().unwrap().doc.clone(); + let err = s.execute(command, &json!({"find": "x", "change": "y", "scope": "selection"})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + } + } + } + + #[test] + fn find_scope_invalid_query_keeps_continuation_and_cross_boundary_nonmatches() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "[", "grep": true, "scope": "selection"})).is_err()); + assert_eq!(s.doc().unwrap().selection, selected); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.find", &json!({"find": "red |", "scope": "selection"})).unwrap(), json!([])); + } + + #[test] + fn find_scope_document_switch_edit_and_reset() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.new", &json!({})).unwrap(); + s.set_active(0); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("story.setText", &json!({"story": sid.0, "text": "red"})).unwrap(); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + s.reset_find_scope(); + assert!(s.find_context.is_none()); + } + + #[test] + fn find_scope_real_preview_restores_before_resolving_bounds() { + let (mut s, sid) = scope_session(""); + s.execute( + "data.source.select", + &json!({ + "bytes": crate::cmd::base64_encode(b"x\nred red | red\n"), "name": "scope.csv" + }), + ) + .unwrap(); + s.execute("data.placeholder.add", &json!({"field": "x", "story": sid.0, "at": 0})).unwrap(); + let template = s.doc().unwrap().doc.clone(); + assert_eq!(template.story(sid).unwrap().text, "<>"); + s.execute("data.preview", &json!({"record": 1})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + // Install a viewport selection in the real preview. text.select is journaled + // and would itself restore the template before selecting. + let st = s.doc_mut().unwrap(); + st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: 7, frame: None, cell: None }); + st.revision += 1; + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + let history = s.doc().unwrap().history.undo.len(); + let err = s.execute("find.change", &p).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "<>"); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.doc().unwrap().preview_stash.is_none()); + assert!(s.find_context.is_none()); + // Valid fresh bounds are allowed after restoration; no cached preview range + // survives. A document query can see the preview, but Next sees the template. + s.execute("data.preview", &json!({"record": 1})).unwrap(); + let document = json!({"find": "red"}); + assert_eq!(s.execute("find.find", &document).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.next", &document).unwrap(), serde_json::Value::Null); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.execute("find.find", &document).unwrap(), json!([])); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + } + + fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { + let (mut s, sid) = scope_session("red"); + scope_select(&mut s, sid, 3, 3); + if note { + s.execute("footnote.insert", &json!({"text": child})).unwrap(); + } else { + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.insert", &json!({"text": child, "raw": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + (s, sid, selected) + } + + #[test] + fn find_scope_children_error_atomically_and_parent_requests_stay_top_level() { + for note in [false, true] { + for child in ["red", "red x", "é red red red red red"] { + let (mut s, sid, mut selected) = child_scope_session(note, child); + selected.anchor = 0; + selected.focus = child.len(); + s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(selected); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let history = s.doc().unwrap().history.undo.len(); + let revision = s.doc().unwrap().revision; + let journal = s.journal.clone(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + let err = s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(err.to_string().contains("not supported")); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(s.journal, journal); + } + } + for explicit in [false, true] { + let mut p = json!({"find": "red", "change": "blue"}); + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_hit(&mut s, &p, sid, 0, 3); + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child); + assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets"); + p["find"] = json!("blue"); + p["change"] = json!("red"); + s.execute("find.change", &p).unwrap(); + } + } + } + let (mut s, _, _) = child_scope_session(false, "red"); + s.execute("table.selectTable", &json!({})).unwrap(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + assert!( + s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err().to_string().contains("not supported") + ); + } + } + } + + #[test] + fn find_scope_threaded_overset_story_uses_logical_offsets() { + let text = format!("{}red", "word ".repeat(100)); + let (mut s, sid) = scope_session(&text); + let a = s.doc().unwrap().doc.story(sid).unwrap().frames[0]; + let next = s.execute("frame.create", &json!({"rect": [150, 36, 20, 1], "content": "text"})).unwrap(); + let b = designcraft_doc::ItemId(next["id"].as_u64().unwrap()); + s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().frames.len(), 2); + scope_select(&mut s, sid, 0, 0); + scope_hit(&mut s, &json!({"find": "red", "scope": "story"}), sid, 500, 503); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "story"})).unwrap(), serde_json::Value::Null); + } + + #[test] + fn find_scope_zero_width_story_explicit_and_document_endpoints_terminate() { + for scope in ["story", "explicit", "document"] { + let (mut s, sid) = scope_session("é🙂"); + scope_select(&mut s, sid, 0, 0); + let mut p = json!({"find": "(?:)", "grep": true, "scope": scope}); + if scope == "explicit" { + p["story"] = json!(sid.0); + } + for pos in [0, 2, 6] { + scope_hit(&mut s, &p, sid, pos, pos); + } + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_revert_releases_context_with_same_document_uid() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("scope.designcraft"); + let (mut s, sid) = scope_session("red red | red"); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let uid = s.doc().unwrap().uid; + s.execute("story.setText", &json!({"story": sid.0, "text": "red red"})).unwrap(); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().uid, uid); + assert!(s.find_context.is_none()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); + } } /// Objects matching Find/Change › Object criteria, in document order (nested ones included). diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 31e4544f4..a0a6ac32c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,7 +15,8 @@ mod editorial; mod endnotes; mod export; mod file; -mod find; +pub(crate) mod find; +pub use find::same_find_domain; mod fonts; mod guides; mod index; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e88a..9c05ecfae 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -29,6 +29,8 @@ pub fn specs() -> Vec { let text = &st.doc.text_story(t.story, t.cell).map(|x| x.text.clone()).unwrap_or_default(); let (a, b) = word_bounds(text, t.focus); st.selection.text = Some(TextSel { anchor: a, focus: b, ..t }); + // A word reselection is external even when place kept the same range for dragging. + st.revision = st.revision.wrapping_add(1); ok() }), cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point (triple click)", has_doc, |s, p| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index caeeb80ba..3064592f0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -260,6 +260,7 @@ pub struct Session { pub cache: Arc, pub(crate) tool: Box, pub journal: Vec<(String, Value)>, + pub(crate) find_context: Option, pub clipboard: Option>, /// Crash-recovery folder (the app sets it; saving or closing a document removes its entry). pub recovery_dir: Option, @@ -300,6 +301,7 @@ impl Session { cache: Arc::new(Cache::new()), tool: designcraft_tools::create("selection"), journal: vec![], + find_context: None, clipboard: None, recovery_dir: None, text_clipboard: None, @@ -358,13 +360,26 @@ impl Session { pub fn doc_mut(&mut self) -> Result<&mut DocState> { self.active_mut().ok_or(EngineError::NoDocument) } + /// Release the transient Find/Change domain when its caller starts or closes a search. + pub fn reset_find_scope(&mut self) { + self.find_context = None; + } + + fn discard_stale_find_scope(&mut self) { + if self.find_context.as_ref().is_some_and(|c| self.active().is_none_or(|st| !c.is_current(st))) { + self.reset_find_scope(); + } + } + pub fn set_active(&mut self, i: usize) { if i < self.docs.len() && self.active != Some(i) { + self.reset_find_scope(); self.stop_data_preview(); self.active = Some(i); } } pub fn add_document(&mut self, d: DocState) -> usize { + self.reset_find_scope(); self.stop_data_preview(); self.docs.push(d); self.active = Some(self.docs.len() - 1); @@ -388,6 +403,7 @@ impl Session { recovery::discard_document(self, i, recovery_dir.as_deref()); } if i < self.docs.len() { + self.reset_find_scope(); self.docs.remove(i); self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) }; } @@ -418,6 +434,8 @@ impl Session { if id != "data.preview" && (spec.undoable || spec.journal) { self.stop_data_preview(); } + // Preview restoration can replace the document before a Find handler resolves bounds. + self.discard_stale_find_scope(); let before = self.active().map(|d| (d.uid, d.doc.clone())); let r = (spec.run)(self, params)?; // Undo, redo or deleting layers can take the active layer away: new objects would land on @@ -455,6 +473,7 @@ impl Session { if spec.journal { self.journal.push((id.to_string(), params.clone())); } + self.discard_stale_find_scope(); Ok(r) } @@ -542,6 +561,7 @@ impl Session { st.doc = Arc::new(doc); st.selection = sel; st.revision += 1; + self.reset_find_scope(); Ok(r) } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5a4de0058..c66bc9272 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -288,13 +288,19 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> "ui.dialog.set" => match app.ui.dialog.as_mut() { Some(d) => { let Some(f) = s("field") else { return err("missing `field`") }; - d.fields.insert(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); + let value = p.get("value").cloned().unwrap_or(Value::Null); + if d.set_field(f, value) { + app.session.reset_find_scope(); + } ok(serde_json::to_value(&*d).unwrap_or_default()) } None => err("no dialog open"), }, "ui.dialog.open" => { let Some(id) = s("id") else { return err("missing `id`") }; + if id == "findChange" || app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + app.session.reset_find_scope(); + } let d = crate::dialogs::Dialog::new(id, p.get("fields").cloned().unwrap_or(Value::Null)); let v = serde_json::to_value(&d).unwrap_or_default(); app.ui.dialog = Some(d); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af1..396cd2500 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -77,6 +77,16 @@ impl Dialog { } Dialog { id: id.into(), fields } } + /// Set a field and report a genuine Find domain/mode transition to the caller. + pub(crate) fn set_field(&mut self, key: &str, value: Value) -> bool { + let before = + (self.id == "findChange" && matches!(key, "scope" | "story" | "objectMode")).then(|| (self.b("objectMode"), find_change_params(self))); + self.fields.insert(key.into(), value); + before.is_some_and(|(object_mode, params)| { + object_mode != self.b("objectMode") || !designcraft_engine::cmd::same_find_domain(¶ms, &find_change_params(self)) + }) + } + fn s(&self, k: &str) -> String { match self.fields.get(k) { Some(Value::String(s)) => s.clone(), @@ -1181,536 +1191,640 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { // forms remain reachable at large UI scales, without shrinking their text. let footer_height = ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y); let body_height = (available.y - ui.min_size().y - footer_height - 12.0 - 2.0 * ui.spacing().item_spacing.y).max(1.0); - egui::ScrollArea::both().id_salt(("dialog_body", &d.id)).min_scrolled_width(1.0).min_scrolled_height(1.0).max_width(width).max_height(body_height).show(ui, |ui| { - match d.id.as_str() { - "newDocument" => { - ui.horizontal(|ui| { - ui.with_layout(if crate::i18n::is_rtl(&app.ui.language) { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Preset")); - let cur = d.s("preset"); - egui::ComboBox::from_id_salt("preset").selected_text(&cur).width(180.0).show_ui(ui, |ui| { - for p in designcraft_doc::build::PRESETS { - if ui.selectable_label(cur == p.name, p.name).clicked() { - d.fields.insert("preset".into(), json!(p.name)); - d.fields.insert("width".into(), json!(format_measure(p.width, p.units))); - d.fields.insert("height".into(), json!(format_measure(p.height, p.units))); - d.fields.insert("facingPages".into(), json!(p.intent == designcraft_doc::Intent::Print)); - } - } + egui::ScrollArea::both() + .id_salt(("dialog_body", &d.id)) + .min_scrolled_width(1.0) + .min_scrolled_height(1.0) + .max_width(width) + .max_height(body_height) + .show(ui, |ui| match d.id.as_str() { + "newDocument" => { + ui.horizontal(|ui| { + ui.with_layout( + if crate::i18n::is_rtl(&app.ui.language) { + egui::Layout::right_to_left(egui::Align::Center) + } else { + egui::Layout::left_to_right(egui::Align::Center) + }, + |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Preset")); + let cur = d.s("preset"); + egui::ComboBox::from_id_salt("preset").selected_text(&cur).width(180.0).show_ui(ui, |ui| { + for p in designcraft_doc::build::PRESETS { + if ui.selectable_label(cur == p.name, p.name).clicked() { + d.fields.insert("preset".into(), json!(p.name)); + d.fields.insert("width".into(), json!(format_measure(p.width, p.units))); + d.fields.insert("height".into(), json!(format_measure(p.height, p.units))); + d.fields.insert("facingPages".into(), json!(p.intent == designcraft_doc::Intent::Print)); + } + } + }); + }, + ); }); - }); - }); - if crate::i18n::is_rtl(&app.ui.language) { - // egui Grid currently supports LTR only. Use explicit RTL rows; - // the numeric editors retain LTR alignment and logical values. - for (first, key, second, other, toggle) in [ - ("Width", "width", "Height", "height", false), - ("Pages", "pages", "Facing Pages", "facingPages", true), - ("Columns", "columns", "Gutter", "gutter", false), - ("Top", "marginTop", "Bottom", "marginBottom", false), - ("Inside", "marginInside", "Outside", "marginOutside", false), - ("Bleed", "bleed", "Primary Text Frame", "primaryTextFrame", true), - ] { - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, first)); - text_field(ui, &mut d, key, 80.0); - if toggle { - check(ui, &mut d, other, crate::i18n::tr(&app.ui.language, second)); - } else { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, second)); - text_field(ui, &mut d, other, 80.0); - } + if crate::i18n::is_rtl(&app.ui.language) { + // egui Grid currently supports LTR only. Use explicit RTL rows; + // the numeric editors retain LTR alignment and logical values. + for (first, key, second, other, toggle) in [ + ("Width", "width", "Height", "height", false), + ("Pages", "pages", "Facing Pages", "facingPages", true), + ("Columns", "columns", "Gutter", "gutter", false), + ("Top", "marginTop", "Bottom", "marginBottom", false), + ("Inside", "marginInside", "Outside", "marginOutside", false), + ("Bleed", "bleed", "Primary Text Frame", "primaryTextFrame", true), + ] { + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, first)); + text_field(ui, &mut d, key, 80.0); + if toggle { + check(ui, &mut d, other, crate::i18n::tr(&app.ui.language, second)); + } else { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, second)); + text_field(ui, &mut d, other, 80.0); + } + }); }); + } + } else { + egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); + text_field(ui, &mut d, "width", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); + text_field(ui, &mut d, "height", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pages")); + text_field(ui, &mut d, "pages", 80.0); + ui.label(""); + check(ui, &mut d, "facingPages", crate::i18n::tr(&app.ui.language, "Facing Pages")); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Columns")); + text_field(ui, &mut d, "columns", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Gutter")); + text_field(ui, &mut d, "gutter", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Top")); + text_field(ui, &mut d, "marginTop", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bottom")); + text_field(ui, &mut d, "marginBottom", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Inside")); + text_field(ui, &mut d, "marginInside", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Outside")); + text_field(ui, &mut d, "marginOutside", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bleed")); + text_field(ui, &mut d, "bleed", 80.0); + ui.label(""); + check(ui, &mut d, "primaryTextFrame", crate::i18n::tr(&app.ui.language, "Primary Text Frame")); + ui.end_row(); }); } - } else { - egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); - text_field(ui, &mut d, "width", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); - text_field(ui, &mut d, "height", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pages")); - text_field(ui, &mut d, "pages", 80.0); - ui.label(""); - check(ui, &mut d, "facingPages", crate::i18n::tr(&app.ui.language, "Facing Pages")); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Columns")); - text_field(ui, &mut d, "columns", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Gutter")); - text_field(ui, &mut d, "gutter", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Top")); - text_field(ui, &mut d, "marginTop", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bottom")); - text_field(ui, &mut d, "marginBottom", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Inside")); - text_field(ui, &mut d, "marginInside", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Outside")); - text_field(ui, &mut d, "marginOutside", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bleed")); - text_field(ui, &mut d, "bleed", 80.0); - ui.label(""); - check(ui, &mut d, "primaryTextFrame", crate::i18n::tr(&app.ui.language, "Primary Text Frame")); - ui.end_row(); - }); } - } - "frameSize" => { - egui::Grid::new("fs").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); - text_field(ui, &mut d, "width", 90.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); - text_field(ui, &mut d, "height", 90.0); - ui.end_row(); - }); - } - "findChange" => { - ui.horizontal(|ui| { - for (label, grep) in [("Text", false), ("GREP", true)] { - if ui.selectable_label(!d.b("objectMode") && d.b("grep") == grep, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - d.fields.insert("grep".into(), json!(grep)); - d.fields.insert("objectMode".into(), json!(false)); + "frameSize" => { + egui::Grid::new("fs").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); + text_field(ui, &mut d, "width", 90.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); + text_field(ui, &mut d, "height", 90.0); + ui.end_row(); + }); + } + "findChange" => { + ui.horizontal(|ui| { + for (label, grep) in [("Text", false), ("GREP", true)] { + if ui + .selectable_label( + !d.b("objectMode") && d.b("grep") == grep, + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label)), + ) + .clicked() + { + if d.set_field("objectMode", json!(false)) { + app.session.reset_find_scope(); + } + d.fields.insert("grep".into(), json!(grep)); + } + } + if ui.selectable_label(d.b("objectMode"), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Object"))).clicked() + && d.set_field("objectMode", json!(true)) + { + app.session.reset_find_scope(); + } + }); + if d.b("objectMode") { + // Find/Change › Object: objects by fill and kind; change their fill or opacity. + let swatches: Vec = app + .session + .active() + .map(|st| st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()) + .unwrap_or_default(); + egui::Grid::new("fco").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); + let cur = d.s(key); + egui::ComboBox::from_id_salt(key) + .selected_text(if cur.is_empty() { any } else { cur.as_str() }) + .width(180.0) + .show_ui(ui, |ui| { + if ui + .selectable_label(cur.is_empty(), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, any))) + .clicked() + { + d.fields.insert(key.into(), json!("")); + } + for w in &swatches { + if ui.selectable_label(*w == cur, w).clicked() { + d.fields.insert(key.into(), json!(w)); + } + } + }); + ui.end_row(); + } + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kind:")); + let cur = d.s("objKind"); + egui::ComboBox::from_id_salt("objKind").selected_text(if cur.is_empty() { "(any)" } else { cur.as_str() }).show_ui( + ui, + |ui| { + for v in ["", "text", "graphic", "shape", "line", "group"] { + if ui + .selectable_label( + cur == v, + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if v.is_empty() { "(any)" } else { v })), + ) + .clicked() + { + d.fields.insert("objKind".into(), json!(v)); + } + } + }, + ); + ui.end_row(); + }); + let mut crit = json!({}); + if !d.s("objFill").is_empty() { + crit["fill"] = json!(d.s("objFill")); + } + if !d.s("objKind").is_empty() { + crit["kind"] = json!(d.s("objKind")); + } + ui.horizontal(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find All"))).clicked() { + let n = app.run("find.objects", crit.clone()).ok().and_then(|r| r["ids"].as_array().map(Vec::len)).unwrap_or(0); + d.fields.insert("status".into(), json!(format!("{n} found"))); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() + && !d.s("objChangeFill").is_empty() + { + let mut p = crit.clone(); + p["change"] = json!({"fill": d.s("objChangeFill")}); + let n = app.run("find.changeObjects", p).ok().and_then(|r| r["changed"].as_u64()).unwrap_or(0); + d.fields.insert("status".into(), json!(format!("{n} changed"))); + } + }); + ui.label(d.s("status")); + } else { + egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); + text_field(ui, &mut d, "find", 260.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + text_field(ui, &mut d, "change", 260.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let cur = d.s("scope"); + egui::ComboBox::from_id_salt("fcscope") + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) + .show_ui(ui, |ui| { + for v in ["document", "story", "selection"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() + && d.set_field("scope", json!(v)) + { + app.session.reset_find_scope(); + } + } + }); + ui.end_row(); + }); + ui.horizontal(|ui| { + check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); + check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); + }); + ui.horizontal(|ui| { + let params0 = find_change_params(&d); + let params = || params0.clone(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { + let r = app.run("find.next", params()); + let msg = match r { + Ok(Value::Null) => "No matches".to_string(), + Ok(_) => "Found".to_string(), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() { + let msg = match app.run("find.change", params()) { + Ok(v) => format!("{} replacement(s) made", v["count"]), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { + let msg = match app.run("find.find", params()) { + Ok(v) => format!("{} match(es)", v.as_array().map(|a| a.len()).unwrap_or(0)), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + }); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); } } - if ui.selectable_label(d.b("objectMode"),crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Object"))).clicked() { - d.fields.insert("objectMode".into(), json!(true)); + } + "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), + "characterStyleOptions" => character_style_options(app, ui, &mut d), + "footnoteOptions" => footnote_options(app, ui, &mut d), + "paragraphRules" => { + let current = d.fields.get("current").cloned().unwrap_or_default(); + paragraph_rules(app, ui, &mut d, "", ¤t); + } + "insertXref" => insert_xref(app, ui, &mut d), + "findFont" => find_font(app, ui, &mut d), + "colorPicker" => color_picker(ui, &mut d), + "preferences" => preferences(app, ui, &mut d, body_height), + "print" => print_dialog(app, ui, &mut d), + "pdfImport" => { + ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); + ui.horizontal(|ui| { + crate::rtl::label( + ui, + crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string()), + ); + text_field(ui, &mut d, "page", 60.0); + }); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Crop to:")); + let cur = d.s("crop"); + let label = |v: &str| match v { + "trim" => crate::i18n::tr(&app.ui.language, "Trim"), + "bleed" => crate::i18n::tr(&app.ui.language, "Bleed"), + "art" => crate::i18n::tr(&app.ui.language, "Art"), + "media" => crate::i18n::tr(&app.ui.language, "Media"), + _ => crate::i18n::tr(&app.ui.language, "Crop"), + }; + egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| { + for v in ["crop", "trim", "bleed", "art", "media"] { + if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() { + d.fields.insert("crop".into(), json!(v)); + } + } + }); + }); + } + "layerOptions" => { + let mut layers = d.fields.get("layers").and_then(Value::as_array).cloned().unwrap_or_default(); + if layers.is_empty() { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "This PDF has no layers.")); } - }); - if d.b("objectMode") { - // Find/Change › Object: objects by fill and kind; change their fill or opacity. - let swatches: Vec = app.session.active().map(|st| st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()).unwrap_or_default(); - egui::Grid::new("fco").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] { + for l in &mut layers { + let mut on = l["visible"].as_bool().unwrap_or(true); + if ui.checkbox(&mut on, l["name"].as_str().unwrap_or("")).changed() { + l["visible"] = json!(on); + } + } + d.fields.insert("layers".into(), json!(layers)); + } + "colorSettings" => { + let profiles = d.fields.get("profiles").and_then(Value::as_array).cloned().unwrap_or_default(); + egui::Grid::new("color_settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, kind) in [("rgb", "RGB:", "rgb"), ("cmyk", "CMYK:", "cmyk")] { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); let cur = d.s(key); - egui::ComboBox::from_id_salt(key).selected_text(if cur.is_empty() { any } else { cur.as_str() }).width(180.0).show_ui(ui, |ui| { - if ui.selectable_label(cur.is_empty(), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, any))).clicked() { - d.fields.insert(key.into(), json!("")); - } - for w in &swatches { - if ui.selectable_label(*w == cur, w).clicked() { - d.fields.insert(key.into(), json!(w)); + egui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| { + for pr in profiles.iter().filter(|p| p["kind"] == kind) { + let name = pr["name"].as_str().unwrap_or(""); + if ui.selectable_label(name == cur, name).clicked() { + d.fields.insert(key.into(), json!(name)); } } }); ui.end_row(); } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kind:")); - let cur = d.s("objKind"); - egui::ComboBox::from_id_salt("objKind").selected_text(if cur.is_empty() { "(any)" } else { cur.as_str() }).show_ui(ui, |ui| { - for v in ["", "text", "graphic", "shape", "line", "group"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if v.is_empty() { "(any)" } else { v }))).clicked() { - d.fields.insert("objKind".into(), json!(v)); - } - } - }); - ui.end_row(); - }); - let mut crit = json!({}); - if !d.s("objFill").is_empty() { - crit["fill"] = json!(d.s("objFill")); - } - if !d.s("objKind").is_empty() { - crit["kind"] = json!(d.s("objKind")); - } - ui.horizontal(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find All"))).clicked() { - let n = app.run("find.objects", crit.clone()).ok().and_then(|r| r["ids"].as_array().map(Vec::len)).unwrap_or(0); - d.fields.insert("status".into(), json!(format!("{n} found"))); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() && !d.s("objChangeFill").is_empty() { - let mut p = crit.clone(); - p["change"] = json!({"fill": d.s("objChangeFill")}); - let n = app.run("find.changeObjects", p).ok().and_then(|r| r["changed"].as_u64()).unwrap_or(0); - d.fields.insert("status".into(), json!(format!("{n} changed"))); - } - }); - ui.label(d.s("status")); - } else { - egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); - text_field(ui, &mut d, "find", 260.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); - text_field(ui, &mut d, "change", 260.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); - let cur = d.s("scope"); - egui::ComboBox::from_id_salt("fcscope").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))).show_ui(ui, |ui| { - for v in ["document", "story", "selection"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() { - d.fields.insert("scope".into(), json!(v)); - } - } - }); - ui.end_row(); - }); - ui.horizontal(|ui| { - check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); - check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); - }); - ui.horizontal(|ui| { - let params0 = 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")}); - let params = || params0.clone(); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { - let r = app.run("find.next", params()); - let msg = match r { - Ok(Value::Null) => "No matches".to_string(), - Ok(_) => "Found".to_string(), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() { - let msg = match app.run("find.change", params()) { - Ok(v) => format!("{} replacement(s) made", v["count"]), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { - let msg = match app.run("find.find", params()) { - Ok(v) => format!("{} match(es)", v.as_array().map(|a| a.len()).unwrap_or(0)), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - }); - if let Some(st) = d.fields.get("status").and_then(Value::as_str) { - ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); - } - } - } - "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), - "characterStyleOptions" => character_style_options(app, ui, &mut d), - "footnoteOptions" => footnote_options(app, ui, &mut d), - "paragraphRules" => { - let current = d.fields.get("current").cloned().unwrap_or_default(); - paragraph_rules(app, ui, &mut d, "", ¤t); - } - "insertXref" => insert_xref(app, ui, &mut d), - "findFont" => find_font(app, ui, &mut d), - "colorPicker" => color_picker(ui, &mut d), - "preferences" => preferences(app, ui, &mut d, body_height), - "print" => print_dialog(app, ui, &mut d), - "pdfImport" => { - ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string())); - text_field(ui, &mut d, "page", 60.0); - }); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Crop to:")); - let cur = d.s("crop"); - let label = |v: &str| match v { - "trim" => crate::i18n::tr(&app.ui.language, "Trim"), - "bleed" => crate::i18n::tr(&app.ui.language, "Bleed"), - "art" => crate::i18n::tr(&app.ui.language, "Art"), - "media" => crate::i18n::tr(&app.ui.language, "Media"), - _ => crate::i18n::tr(&app.ui.language, "Crop"), - }; - egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| { - for v in ["crop", "trim", "bleed", "art", "media"] { - if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() { - d.fields.insert("crop".into(), json!(v)); - } - } - }); - }); - } - "layerOptions" => { - let mut layers = d.fields.get("layers").and_then(Value::as_array).cloned().unwrap_or_default(); - if layers.is_empty() { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "This PDF has no layers.")); - } - for l in &mut layers { - let mut on = l["visible"].as_bool().unwrap_or(true); - if ui.checkbox(&mut on, l["name"].as_str().unwrap_or("")).changed() { - l["visible"] = json!(on); - } - } - d.fields.insert("layers".into(), json!(layers)); - } - "colorSettings" => { - let profiles = d.fields.get("profiles").and_then(Value::as_array).cloned().unwrap_or_default(); - egui::Grid::new("color_settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (key, label, kind) in [("rgb", "RGB:", "rgb"), ("cmyk", "CMYK:", "cmyk")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); - let cur = d.s(key); - egui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| { - for pr in profiles.iter().filter(|p| p["kind"] == kind) { - let name = pr["name"].as_str().unwrap_or(""); - if ui.selectable_label(name == cur, name).clicked() { - d.fields.insert(key.into(), json!(name)); - } - } - }); - ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:")); - let cur = d.s("intent"); - egui::ComboBox::from_id_salt("ws_intent") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, designcraft_color::cms::Intent::parse(&cur).map_or("", |i| i.label())))) - .show_ui(ui, |ui| { - for i in designcraft_color::cms::Intent::ALL { - if ui.selectable_label(i.id() == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, i.label()))).clicked() { - d.fields.insert("intent".into(), json!(i.id())); - } - } - }); - ui.end_row(); - }); - check(ui, &mut d, "bpc", crate::i18n::tr(&app.ui.language, "Use Black Point Compensation")); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Profile…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("icc")) - { - match app.session.execute("color.loadProfile", &json!({"path": path})) { - Ok(_) => { - let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); - d.fields.insert("profiles".into(), f["profiles"].clone()); - } - Err(e) => app.status(format!("Color Settings: {e}")), - } - } - } - "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), - "userDictionary" => user_dictionary(app, ui, &mut d), - "menus" => { - text_field(ui, &mut d, "query", 260.0); - let q = d.s("query").to_lowercase(); - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (menu, label) in crate::menus::all_menu_items().into_iter().filter(|(m, l)| q.is_empty() || m.to_lowercase().contains(&q) || l.to_lowercase().contains(&q)) { - let key = crate::menus::menu_key(&menu, &label); - let mut visible = !app.ui.hidden_menu_items.contains(&key); - if ui.checkbox(&mut visible, crate::rtl::widget(ui, format!("{} › {}", crate::i18n::tr(&app.ui.language, &menu), crate::i18n::tr(&app.ui.language, &label)))).changed() { - let _ = app.run("window.hideMenuItem", json!({"item": key, "hidden": !visible})); - } - } - }); - } - "importOptions" => { - ui.label(egui::RichText::new(d.s("path")).size(10.5)); - check(ui, &mut d, "removeStyles", crate::i18n::tr(&app.ui.language, "Remove Styles and Formatting from Text and Tables")); - if !d.b("removeStyles") { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Name Conflicts")).font(semibold(12.0))); - let conflicts: Vec = d.fields.get("conflicts").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - ui.label(if conflicts.is_empty() { crate::i18n::tr(&app.ui.language, "No conflicts").to_string() } else { format!("{} conflict(s): {}", conflicts.len(), conflicts.join(", ")) }); - let cur = d.s("styleConflicts"); - ui.horizontal(|ui| { - for (v, l) in [("useExisting", "Use Document Style Definition"), ("redefine", "Redefine Document Style"), ("autoRename", "Auto Rename")] { - if ui.radio(cur == v, l).clicked() { - d.fields.insert("styleConflicts".into(), json!(v)); - } - } - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Mapping")).font(semibold(12.0))); - let names: Vec = app.session.active().map(|s| s.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); - let imported: Vec = d.fields.get("styles").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - egui::Grid::new("imp_map").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| { - for w in &imported { - ui.label(w); - let mapped = d.fields.get("map").and_then(|m| m.get(w)).and_then(Value::as_str).unwrap_or("").to_string(); - let shown = if mapped.is_empty() { "(import)".to_string() } else { mapped.clone() }; - egui::ComboBox::from_id_salt(("imp_map", w)).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { - if ui.selectable_label(mapped.is_empty(),crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(import)"))).clicked() - && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:")); + let cur = d.s("intent"); + egui::ComboBox::from_id_salt("ws_intent") + .selected_text(crate::rtl::widget( + ui, + crate::i18n::tr(&app.ui.language, designcraft_color::cms::Intent::parse(&cur).map_or("", |i| i.label())), + )) + .show_ui(ui, |ui| { + for i in designcraft_color::cms::Intent::ALL { + if ui + .selectable_label(i.id() == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, i.label()))) + .clicked() { - m.remove(w); + d.fields.insert("intent".into(), json!(i.id())); } - for n in &names { - if ui.selectable_label(*n == mapped, n).clicked() - && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) - { - m.insert(w.clone(), json!(n)); - } + } + }); + ui.end_row(); + }); + check(ui, &mut d, "bpc", crate::i18n::tr(&app.ui.language, "Use Black Point Compensation")); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Profile…"))).clicked() + && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("icc")) + { + match app.session.execute("color.loadProfile", &json!({"path": path})) { + Ok(_) => { + let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); + d.fields.insert("profiles".into(), f["profiles"].clone()); + } + Err(e) => app.status(format!("Color Settings: {e}")), + } + } + } + "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), + "userDictionary" => user_dictionary(app, ui, &mut d), + "menus" => { + text_field(ui, &mut d, "query", 260.0); + let q = d.s("query").to_lowercase(); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (menu, label) in crate::menus::all_menu_items() + .into_iter() + .filter(|(m, l)| q.is_empty() || m.to_lowercase().contains(&q) || l.to_lowercase().contains(&q)) + { + let key = crate::menus::menu_key(&menu, &label); + let mut visible = !app.ui.hidden_menu_items.contains(&key); + if ui + .checkbox( + &mut visible, + crate::rtl::widget( + ui, + format!("{} › {}", crate::i18n::tr(&app.ui.language, &menu), crate::i18n::tr(&app.ui.language, &label)), + ), + ) + .changed() + { + let _ = app.run("window.hideMenuItem", json!({"item": key, "hidden": !visible})); + } + } + }); + } + "importOptions" => { + ui.label(egui::RichText::new(d.s("path")).size(10.5)); + check(ui, &mut d, "removeStyles", crate::i18n::tr(&app.ui.language, "Remove Styles and Formatting from Text and Tables")); + if !d.b("removeStyles") { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Name Conflicts")).font(semibold(12.0))); + let conflicts: Vec = d + .fields + .get("conflicts") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + ui.label(if conflicts.is_empty() { + crate::i18n::tr(&app.ui.language, "No conflicts").to_string() + } else { + format!("{} conflict(s): {}", conflicts.len(), conflicts.join(", ")) + }); + let cur = d.s("styleConflicts"); + ui.horizontal(|ui| { + for (v, l) in [ + ("useExisting", "Use Document Style Definition"), + ("redefine", "Redefine Document Style"), + ("autoRename", "Auto Rename"), + ] { + if ui.radio(cur == v, l).clicked() { + d.fields.insert("styleConflicts".into(), json!(v)); + } + } + }); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Mapping")).font(semibold(12.0))); + let names: Vec = + app.session.active().map(|s| s.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); + let imported: Vec = d + .fields + .get("styles") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + egui::Grid::new("imp_map").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| { + for w in &imported { + ui.label(w); + let mapped = d.fields.get("map").and_then(|m| m.get(w)).and_then(Value::as_str).unwrap_or("").to_string(); + let shown = if mapped.is_empty() { "(import)".to_string() } else { mapped.clone() }; + egui::ComboBox::from_id_salt(("imp_map", w)).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui( + ui, + |ui| { + if ui + .selectable_label( + mapped.is_empty(), + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(import)")), + ) + .clicked() + && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + { + m.remove(w); + } + for n in &names { + if ui.selectable_label(*n == mapped, n).clicked() + && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + { + m.insert(w.clone(), json!(n)); + } + } + }, + ); + ui.end_row(); + } + }); + }); + } + } + "closeDocument" => { + let text = crate::i18n::tr(&app.ui.language, "“{}” has changes that are not saved. Save them before closing?") + .replace("{}", &d.s("title")); + crate::rtl::label(ui, text); + } + "newWorkspace" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); + text_field(ui, &mut d, "name", 220.0); + }); + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Saves which bars show and where the panels are.")).size(10.5), + ); + } + "fittingOptions" => { + check(ui, &mut d, "autoFit", crate::i18n::tr(&app.ui.language, "Auto-Fit")); + ui.add_space(4.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Content Fitting")).font(semibold(12.0))); + let cur = d.s("fitting"); + let label = |v: &str| match v { + "fillProportionally" => crate::i18n::tr(&app.ui.language, "Fill Frame Proportionally"), + "fitProportionally" => crate::i18n::tr(&app.ui.language, "Fit Content Proportionally"), + "fitContentToFrame" => crate::i18n::tr(&app.ui.language, "Fit Content to Frame"), + "centerContent" => crate::i18n::tr(&app.ui.language, "Center Content"), + _ => crate::i18n::tr(&app.ui.language, "None"), + }; + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fitting:")); + egui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui(ui, |ui| { + for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { + d.fields.insert("fitting".into(), json!(v)); + } + } + }); + }); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align From:")); + let a = d.n("align").unwrap_or(4.0) as u8; + egui::Grid::new("ff_align").spacing([2.0, 2.0]).show(ui, |ui| { + for row in 0..3u8 { + for col in 0..3u8 { + let i = row * 3 + col; + let (r, resp) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); + let c = ui.visuals().text_color(); + if a == i { + ui.painter().rect_filled(r.shrink(2.0), 1.0, c); + } else { + ui.painter().rect_stroke(r.shrink(3.0), 1.0, egui::Stroke::new(1.0, c), egui::StrokeKind::Inside); } - }); + if resp.clicked() { + d.fields.insert("align".into(), json!(i)); + } + } ui.end_row(); } }); }); - } - } - "closeDocument" => { - let text = crate::i18n::tr(&app.ui.language, "“{}” has changes that are not saved. Save them before closing?").replace("{}", &d.s("title")); - crate::rtl::label(ui, text); - } - "newWorkspace" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); - text_field(ui, &mut d, "name", 220.0); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Saves which bars show and where the panels are.")).size(10.5)); - } - "fittingOptions" => { - check(ui, &mut d, "autoFit", crate::i18n::tr(&app.ui.language, "Auto-Fit")); - ui.add_space(4.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Content Fitting")).font(semibold(12.0))); - let cur = d.s("fitting"); - let label = |v: &str| match v { - "fillProportionally" => crate::i18n::tr(&app.ui.language, "Fill Frame Proportionally"), - "fitProportionally" => crate::i18n::tr(&app.ui.language, "Fit Content Proportionally"), - "fitContentToFrame" => crate::i18n::tr(&app.ui.language, "Fit Content to Frame"), - "centerContent" => crate::i18n::tr(&app.ui.language, "Center Content"), - _ => crate::i18n::tr(&app.ui.language, "None"), - }; - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fitting:")); - egui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui(ui, |ui| { - for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { - d.fields.insert("fitting".into(), json!(v)); - } + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Crop Amount")).font(semibold(12.0))); + egui::Grid::new("ff_crop").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + for (k, l) in [("cropTop", "Top:"), ("cropLeft", "Left:")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 70.0); } + ui.end_row(); + for (k, l) in [("cropBottom", "Bottom:"), ("cropRight", "Right:")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 70.0); + } + ui.end_row(); }); - }); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align From:")); - let a = d.n("align").unwrap_or(4.0) as u8; - egui::Grid::new("ff_align").spacing([2.0, 2.0]).show(ui, |ui| { - for row in 0..3u8 { - for col in 0..3u8 { - let i = row * 3 + col; - let (r, resp) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); - let c = ui.visuals().text_color(); - if a == i { - ui.painter().rect_filled(r.shrink(2.0), 1.0, c); - } else { - ui.painter().rect_stroke(r.shrink(3.0), 1.0, egui::Stroke::new(1.0, c), egui::StrokeKind::Inside); - } - if resp.clicked() { - d.fields.insert("align".into(), json!(i)); + } + "qrCode" => { + let cur = d.s("type"); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Type:")); + let label = |v: &str| match v { + "text" => crate::i18n::tr(&app.ui.language, "Plain Text"), + "sms" => crate::i18n::tr(&app.ui.language, "Text Message"), + "email" => crate::i18n::tr(&app.ui.language, "Email"), + "vcard" => crate::i18n::tr(&app.ui.language, "Business Card"), + _ => crate::i18n::tr(&app.ui.language, "Web Hyperlink"), + }; + egui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui(ui, |ui| { + for v in ["url", "text", "sms", "email", "vcard"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { + d.fields.insert("type".into(), json!(v)); } } + }); + }); + let fields: &[(&str, &str)] = match cur.as_str() { + "sms" => &[("number", "Cell Number:"), ("message", "Message:")], + "email" => &[("to", "Email Address:"), ("subject", "Subject:"), ("body", "Message:")], + "vcard" => &[("name", "Name:"), ("org", "Organization:"), ("phone", "Phone:"), ("email", "Email:"), ("url", "URL:")], + "text" => &[("content", "Text:")], + _ => &[("content", "URL:")], + }; + egui::Grid::new("qr_fields").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (k, l) in fields { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 260.0); ui.end_row(); } - }); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Crop Amount")).font(semibold(12.0))); - egui::Grid::new("ff_crop").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - for (k, l) in [("cropTop", "Top:"), ("cropLeft", "Left:")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 70.0); - } - ui.end_row(); - for (k, l) in [("cropBottom", "Bottom:"), ("cropRight", "Right:")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 70.0); - } - ui.end_row(); - }); - } - "qrCode" => { - let cur = d.s("type"); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Type:")); - let label = |v: &str| match v { - "text" => crate::i18n::tr(&app.ui.language, "Plain Text"), - "sms" => crate::i18n::tr(&app.ui.language, "Text Message"), - "email" => crate::i18n::tr(&app.ui.language, "Email"), - "vcard" => crate::i18n::tr(&app.ui.language, "Business Card"), - _ => crate::i18n::tr(&app.ui.language, "Web Hyperlink"), - }; - egui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui(ui, |ui| { - for v in ["url", "text", "sms", "email", "vcard"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { - d.fields.insert("type".into(), json!(v)); - } - } - }); - }); - let fields: &[(&str, &str)] = match cur.as_str() { - "sms" => &[("number", "Cell Number:"), ("message", "Message:")], - "email" => &[("to", "Email Address:"), ("subject", "Subject:"), ("body", "Message:")], - "vcard" => &[("name", "Name:"), ("org", "Organization:"), ("phone", "Phone:"), ("email", "Email:"), ("url", "URL:")], - "text" => &[("content", "Text:")], - _ => &[("content", "URL:")], - }; - egui::Grid::new("qr_fields").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (k, l) in fields { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 260.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:")); + text_field(ui, &mut d, "color", 160.0); ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:")); - text_field(ui, &mut d, "color", 160.0); - ui.end_row(); - }); - } - "polygonSettings" => { - egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Sides:")); - text_field(ui, &mut d, "sides", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Star Inset:")); - ui.horizontal(|ui| { - text_field(ui, &mut d, "starInset", 60.0); - ui.label("%"); }); - ui.end_row(); - }); - } - STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), - id if id.starts_with("cmd:") => command_form(app, ui, &mut d), - "goToPage" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); - text_field(ui, &mut d, "page", 80.0); - }); - } - "ruby" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Ruby:")); - text_field(ui, &mut d, "text", 200.0); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Set over the selected text; empty removes it.")).size(11.0)); - } - "insertTable" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); - egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); - text_field(ui, &mut d, "bodyRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); - text_field(ui, &mut d, "columns", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); - text_field(ui, &mut d, "headerRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); - text_field(ui, &mut d, "footerRows", 60.0); - ui.end_row(); - }); - } - "textFrameOptions" => { - seed_text_frame_options(app, &mut d); - text_frame_options_dialog(app, ui, &mut d); - } - "documentSetup" => document_setup(app, ui, &mut d), - "pdfExport" => pdf_export(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")); - } - _ => {} - } - }); + "polygonSettings" => { + egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Sides:")); + text_field(ui, &mut d, "sides", 60.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Star Inset:")); + ui.horizontal(|ui| { + text_field(ui, &mut d, "starInset", 60.0); + ui.label("%"); + }); + ui.end_row(); + }); + } + STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), + id if id.starts_with("cmd:") => command_form(app, ui, &mut d), + "goToPage" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); + text_field(ui, &mut d, "page", 80.0); + }); + } + "ruby" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Ruby:")); + text_field(ui, &mut d, "text", 200.0); + }); + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Set over the selected text; empty removes it.")).size(11.0), + ); + } + "insertTable" => { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); + egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); + text_field(ui, &mut d, "bodyRows", 60.0); + crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); + text_field(ui, &mut d, "columns", 60.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); + text_field(ui, &mut d, "headerRows", 60.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); + text_field(ui, &mut d, "footerRows", 60.0); + ui.end_row(); + }); + } + "textFrameOptions" => { + seed_text_frame_options(app, &mut d); + text_frame_options_dialog(app, ui, &mut d); + } + "documentSetup" => document_setup(app, ui, &mut d), + "pdfExport" => pdf_export(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); let ok_label = if d.id == "closeDocument" { " Save " } else { " OK " }; ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( - egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, ok_label)).color(egui::Color32::WHITE))) - .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), + egui::Button::new(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, ok_label)).color(egui::Color32::WHITE), + )) + .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) @@ -1742,12 +1856,26 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { /// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing /// dialog puts the document back as it was. pub fn cancel(app: &mut DesignApp) { + if app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + app.session.reset_find_scope(); + } if app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { app.session.cancel_interaction(); } app.cancel_pdf_import(); } +fn find_change_params(d: &Dialog) -> Value { + let mut p = 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"), + }); + if let Some(story) = d.fields.get("story") { + p["story"] = story.clone(); + } + p +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -2172,8 +2300,12 @@ pub fn confirm(app: &mut DesignApp) -> Result { 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")})), + "findChange" => { + let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) }; + // Confirmation consumes retained bounds before closing, even on an error. + app.session.reset_find_scope(); + result + } STEP_AND_REPEAT => { // The preview goes; the copies are made once, as one undo step. if d.fields.contains_key(PREVIEWED) { @@ -4022,6 +4154,721 @@ mod tests { } } + fn scope_control(app: &mut DesignApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (request, _reply) = crate::control::ControlRequest::new(method, params); + let crate::control::Outcome::Done(value) = crate::control::handle(app, ctx, &request) else { panic!("ordinary control response") }; + assert_eq!(value["ok"], true, "{value}"); + value["result"].clone() + } + + fn scope_click(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, label: &str) { + let labels = dialog_frame(app, ctx, screen, vec![]); + let pos = visible_label(&labels, screen, label).center(); + click_dialog(app, ctx, screen, pos); + } + + fn scope_dialog() -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + scope_dialog_with_selection("red red | red", 7) + } + + fn scope_dialog_with_selection(text: &str, end: usize) -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + scope_dialog_with_tool(text, end, None) + } + + fn scope_dialog_with_tool( + text: &str, + end: usize, + tool: Option<&str>, + ) -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.language = "en".into(); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); + let sid = designcraft_engine::doc::StoryId(frame["story"].as_u64().unwrap()); + let ctx = dialog_context("en"); + if let Some(tool) = tool { + scope_control(&mut app, &ctx, "ui.tool.select", json!({"tool": tool})); + } + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": end})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for (field, value) in [("find", "red"), ("change", "unused")] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + scope_click(&mut app, &ctx, screen, "document"); + scope_click(&mut app, &ctx, screen, "selection"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("scope"), "selection"); + (app, ctx, screen, sid) + } + + fn scope_selected(app: &DesignApp, sid: designcraft_engine::doc::StoryId, a: usize, b: usize) { + let t = app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.story, t.range(), t.cell), (sid, a..b, None)); + } + + fn scope_state_unchanged(app: &DesignApp, before: &designcraft_engine::DocState) { + let after = app.session.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); + assert_eq!(after.uid, before.uid); + assert_eq!(after.revision, before.revision); + assert_eq!(after.selection, before.selection); + assert_eq!(after.history.limit, before.history.limit); + for (actual, expected) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] { + assert_eq!(actual.len(), expected.len()); + for (a, b) in actual.iter().zip(expected) { + assert_eq!(a.label, b.label); + assert_eq!(a.selection, b.selection); + assert!(std::sync::Arc::ptr_eq(&a.doc, &b.doc)); + } + } + } + + #[test] + fn find_scope_dialog_control_effective_noops_retain_bounds() { + for (field, value) in [("objectMode", json!(false)), ("story", Value::Null)] { + let (mut app, ctx, screen, sid) = scope_dialog(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key(field)); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields[field], value); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[test] + fn find_scope_dialog_explicit_story_ignored_scope_preserves_grep_cursor() { + for physical in [false, true] { + for (text, pattern, hits, changed) in [ + ("é", "é|(?:)", vec![(0, 2)], "X"), + ("é🙂", "é|(?:)", vec![(0, 2), (6, 6)], "X🙂X"), + ("é🙂", "(?:)", vec![(0, 0), (2, 2), (6, 6)], "XéX🙂X"), + ] { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection(text, 0); + for (field, value) in [("find", json!(pattern)), ("change", json!("X")), ("story", json!(sid.0))] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "GREP"); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} match(es)", hits.len())); + for &(a, b) in &hits { + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "Found"); + scope_selected(&app, sid, a, b); + let before = app.session.doc().unwrap().clone(); + let cur = app.ui.dialog.as_ref().unwrap().s("scope"); + let next = if cur == "selection" { "document" } else { "selection" }; + if physical { + scope_click(&mut app, &ctx, screen, &cur); + scope_click(&mut app, &ctx, screen, next); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); + } + assert_eq!(app.ui.dialog.as_ref().unwrap().s("scope"), next); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["story"], json!(sid.0)); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} match(es)", hits.len())); + } + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + // An ignored scope change must not revive an exhausted endpoint either. + let before = app.session.doc().unwrap().clone(); + let cur = app.ui.dialog.as_ref().unwrap().s("scope"); + let next = if cur == "selection" { "document" } else { "selection" }; + if physical { + scope_click(&mut app, &ctx, screen, &cur); + scope_click(&mut app, &ctx, screen, next); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); + } + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} replacement(s) made", hits.len())); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, changed); + } + } + } + + #[test] + fn find_scope_dialog_explicit_story_transitions_reset_cursor() { + for remove_override in [false, true] { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection("é🙂", 0); + let other = app.run("frame.create", json!({"rect": [180, 36, 100, 100], "content": "text", "text": "other"})).unwrap(); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + for (field, value) in [("find", json!("(?:)")), ("story", json!(sid.0))] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "GREP"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 0); + let before = app.session.doc().unwrap().clone(); + let value = if remove_override { Value::Null } else { other["story"].clone() }; + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": value})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": sid.0})); + scope_state_unchanged(&app, &before); + // Away and back still releases continuation even if no Find command intervenes. + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 0); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 2, 2); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "é🙂"); + let other_sid = designcraft_engine::doc::StoryId(other["story"].as_u64().unwrap()); + assert_eq!(app.session.doc().unwrap().doc.story(other_sid).unwrap().text, "other"); + } + } + + #[test] + fn find_scope_dialog_control_mode_changes_release_bounds() { + for object_mode in [false, true] { + let (mut app, ctx, _, sid) = scope_dialog(); + if object_mode { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "objectMode", "value": true})); + } + // Direct Session consumers share the retained domain even while Object is shown. + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count.as_array().unwrap().len(), 2); + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.next", "params": p})); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "objectMode", "value": !object_mode})); + assert_eq!(app.ui.dialog.as_ref().unwrap().b("objectMode"), !object_mode); + scope_state_unchanged(&app, &before); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + } + + #[test] + fn find_scope_dialog_tool_replacement_releases_bounds() { + for (tool, replacement) in [("page", "cmd:layout.pageSize"), ("rectangleFrame", "frameSize")] { + let (mut app, ctx, screen, sid) = scope_dialog_with_tool("red red | red", 7, Some(tool)); + assert_eq!(app.session.tool_id(), tool); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + let off = app.session.layout().xf(designcraft_engine::doc::SpreadRef::Doc(0)).translation(); + let x = off.x + before.doc.spreads[0].pages[0].x + 200.0; + let y = off.y + 200.0; + let events = if tool == "page" { + json!([{"kind": "up", "x": x, "y": y}]) + } else { + json!([{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]) + }; + // Real tool -> Action::Dialog -> after_engine; no injected UI request. + scope_control(&mut app, &ctx, "ui.pointer", json!({"events": events})); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, replacement); + assert!(app.session.ui_requests.is_empty()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Cancel"); + assert!(app.ui.dialog.is_none()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + scope_state_unchanged(&app, &before); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[test] + fn find_scope_dialog_tool_without_replacement_retains_bounds() { + for tool in ["page", "rectangleFrame"] { + let (mut app, ctx, screen, sid) = scope_dialog_with_tool("red red | red", 7, Some(tool)); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let fields = app.ui.dialog.as_ref().unwrap().fields.clone(); + scope_control(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "move", "x": 200, "y": 200}]})); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "findChange"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields, fields); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_native_pdf_replacement_releases_bounds() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("three.pdf"); + std::fs::write(&path, crate::browser_import_tests::pdf()).unwrap(); + for dismissal in ["Cancel", "Escape", "control"] { + let (mut app, ctx, screen, sid) = scope_dialog(); + let picked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_pick = picked.clone(); + let seen_read = read.clone(); + let pick_path = path.to_string_lossy().to_string(); + let read_path = pick_path.clone(); + app.services.pick_open = Some(Box::new(move |purpose| { + assert_eq!(purpose, "place"); + seen_pick.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Some(pick_path.clone()) + })); + app.services.read = Some(Box::new(move |path| { + assert_eq!(path, read_path); + seen_read.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + std::fs::read(path).map_err(|e| e.to_string()) + })); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + // Exercise engine.execute -> UiRequest::Pick -> native host PDF replacement. + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 1); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.id, "pdfImport"); + assert_eq!(dialog.s("path"), path.to_string_lossy()); + assert_eq!(dialog.fields["pages"], 3); + assert_eq!(dialog.s("page"), "1"); + assert!(!dialog.b("async")); + assert_eq!(dialog.fields["target"], json!(before.uid)); + assert!(app.session.ui_requests.is_empty()); + assert!(app.pending_pdf.is_none()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + match dismissal { + "Cancel" => scope_click(&mut app, &ctx, screen, "Cancel"), + "Escape" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + } + _ => panic!("known dismissal"), + } + assert!(app.ui.dialog.is_none()); + assert!(app.pending_pdf.is_none()); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + scope_state_unchanged(&app, &before); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_native_picker_cancel_retains_bounds() { + let (mut app, ctx, screen, sid) = scope_dialog(); + let picked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = picked.clone(); + app.services.pick_open = Some(Box::new(move |purpose| { + assert_eq!(purpose, "place"); + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + None + })); + let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_read = read.clone(); + app.services.read = Some(Box::new(move |_| { + seen_read.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err("canceled picker must not read".into()) + })); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + let fields = app.ui.dialog.as_ref().unwrap().fields.clone(); + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "findChange"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields, fields); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + + #[test] + fn find_scope_dialog_exact_baseline_buttons() { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection("red red", 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red"); + } + + #[test] + fn find_scope_dialog_grep_tab_alone_restarts_original_selection() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "GREP"); + assert!(app.ui.dialog.as_ref().unwrap().b("grep")); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("find"), "red"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Text"); + assert!(!app.ui.dialog.as_ref().unwrap().b("grep")); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + } + + #[test] + fn find_scope_dialog_buttons_retain_nonempty_hits_and_change_all() { + let (mut app, ctx, screen, sid) = scope_dialog(); + let history = app.session.doc().unwrap().history.undo.len(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history + 1); + scope_click(&mut app, &ctx, screen, "Cancel"); + assert!(app.ui.dialog.is_none()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + app.run("edit.redo", json!({})).unwrap(); + let native = app.run("file.serialize", json!({})).unwrap(); + app.run("file.openBytes", json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + } + + fn scope_confirm_case(action: &str) { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + match action { + "OK" => scope_click(&mut app, &ctx, screen, "OK"), + "Enter" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + assert_eq!(scope_control(&mut app, &ctx, "ui.dialog.confirm", json!({}))["count"], 2); + } + _ => panic!("known confirmation route"), + } + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + // The closed dialog cannot leave its old seven-byte range for a later direct query. + let matches = app.run("find.find", json!({"find": "blue", "scope": "selection"})).unwrap(); + assert_eq!(matches.as_array().unwrap().len(), 0); + } + + #[test] + fn find_scope_dialog_ok_consumes_retained_bounds() { + scope_confirm_case("OK"); + } + + #[test] + fn find_scope_dialog_enter_consumes_retained_bounds() { + scope_confirm_case("Enter"); + } + + #[test] + fn find_scope_dialog_control_confirm_consumes_retained_bounds() { + scope_confirm_case("control"); + } + + #[test] + fn find_scope_dialog_query_reselection_cancel_and_reopen() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "grep", "value": true})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "find", "value": "r.d"})); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 7})).unwrap(); + app.run("find.next", json!({"find": "red", "scope": "selection"})).unwrap(); + scope_control(&mut app, &ctx, "ui.dialog.open", json!({"id": "findChange", "fields": {"find": "red", "scope": "selection"}})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + app.run("text.select", json!({"story": sid.0, "anchor": 4, "focus": 7})).unwrap(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + app.run("app.findChange", json!({})).unwrap(); + for (field, value) in [("find", "red"), ("scope", "selection")] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + } + + #[test] + fn find_scope_dialog_word_reselection_limits_count_and_change_buttons() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let selected = app.session.doc().unwrap().selection.clone(); + let frame = selected.text.unwrap().frame.unwrap(); + let point = { + let st = app.session.doc().unwrap(); + let cs = app.session.cache.get(&st.doc, sid, None); + let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); + let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); + assert_eq!(cs.frames[fi].frame, frame); + assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1)); + let loc = st.doc.find(frame).unwrap(); + st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner + }; + let before = app.session.doc().unwrap().doc.clone(); + let revision = app.session.doc().unwrap().revision; + app.run("text.selectWord", json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); + assert_eq!(app.session.doc().unwrap().selection, selected); + assert_eq!(app.session.doc().unwrap().revision, revision + 1); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_failed_native_drop_alert_releases_bounds() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("broken.designcraft"); + std::fs::write(&path, b"not a native document").unwrap(); + for dismissal in ["OK", "Escape", "control"] { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().doc.clone(); + let selected = app.session.doc().unwrap().selection.clone(); + let revision = app.session.doc().unwrap().revision; + let history = app.session.doc().unwrap().history.undo.len(); + let uid = app.session.doc().unwrap().uid; + let error = app.open_dropped(&path.to_string_lossy()).unwrap_err(); + let alert = app.ui.dialog.as_ref().unwrap(); + assert_eq!(alert.id, "alert"); + assert_eq!(alert.s("title"), "Can't Open the File"); + assert_eq!(alert.s("message"), error); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + assert_eq!(app.session.doc().unwrap().selection, selected); + assert_eq!(app.session.doc().unwrap().revision, revision); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + match dismissal { + "OK" => scope_click(&mut app, &ctx, screen, "OK"), + "Escape" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + } + _ => panic!("known dismissal"), + } + assert!(app.ui.dialog.is_none()); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(app.run("find.find", p.clone()).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + assert_eq!(app.run("find.change", p).unwrap(), json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history + 1); + } + } + + #[test] + fn find_scope_dialog_child_errors_and_explicit_parent() { + let (mut app, ctx, screen, sid) = scope_dialog(); + app.run("text.select", json!({"story": sid.0, "anchor": 13, "focus": 13})).unwrap(); + app.run("table.insert", json!({"rows": 1, "cols": 1})).unwrap(); + app.run("text.insert", json!({"text": "red child", "raw": true})).unwrap(); + let child = app.session.doc().unwrap().selection.text.unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = app.session.doc().unwrap().history.undo.len(); + for label in ["Count", "Find Next", "Change All"] { + scope_click(&mut app, &ctx, screen, label); + assert!(app.ui.dialog.as_ref().unwrap().s("status").contains("not supported")); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + } + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": sid.0})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "3 match(es)"); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "3 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.text_story(sid, child.cell).unwrap().text, "red child"); + assert!(app.session.doc().unwrap().doc.story(sid).unwrap().text.starts_with("blue blue | blue")); + } + + #[test] + fn find_scope_dialog_error_confirmation_and_escape_release_context() { + for escape in [false, true] { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + if escape { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "find", "value": "["})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "grep", "value": true})); + scope_click(&mut app, &ctx, screen, "OK"); + } + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + } + } + + #[test] + fn find_scope_dialog_domain_and_object_changes_release_original_range() { + for route in ["scope", "object", "menu", "command"] { + let (mut app, ctx, screen, _) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + match route { + "scope" => { + for scope in ["story", "selection"] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": scope})); + } + } + "object" => { + scope_click(&mut app, &ctx, screen, "Object"); + scope_click(&mut app, &ctx, screen, "Text"); + } + "menu" => crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null), + "command" => { + app.run("app.newDocumentDialog", json!({})).unwrap(); + } + _ => panic!("known reset route"), + } + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + } + } + #[test] fn dialog_title_and_actions_fit_scaled_viewports() { for language in ["en", "ar"] { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..1082dd160 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -622,6 +622,7 @@ impl DesignApp { pub fn after_engine(&mut self) { let reqs = std::mem::take(&mut self.session.ui_requests); for r in reqs { + let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); match r { UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)), UiRequest::View { params } => canvas::apply_view_request(self, ¶ms), @@ -630,6 +631,10 @@ impl DesignApp { let _ = self.pick_and_open(&purpose); } } + // Release only when the request actually replaces Find, including a native PDF picker. + if was_find && !self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + self.session.reset_find_scope(); + } } // New documents get a fitted view. if let Some(st) = self.session.active() { @@ -986,6 +991,9 @@ impl DesignApp { 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" }; + if self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + self.session.reset_find_scope(); + } self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e}))); } else if let Ok(result) = &r { let warnings: Vec<&str> = result @@ -2108,7 +2116,7 @@ mod browser_import_tests { result.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1)); result.into_bytes() } - fn pdf() -> Vec { + pub(super) fn pdf() -> Vec { pdf_pages(3) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..13d3a54d9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -752,6 +752,15 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { /// Run a UI command; `None` if `id` isn't one. pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option> { + let was_find = app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); + let result = run_ui_inner(app, id, p); + if was_find && app.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { + app.session.reset_find_scope(); + } + result +} + +fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option> { if let Some(result) = crate::panel_docking::command(app, id, p) { return Some(result); } @@ -957,6 +966,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + app.session.reset_find_scope(); app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({}))); Ok(Value::Null) } @@ -1880,6 +1890,14 @@ pub fn close_document(app: &mut DesignApp, index: Option) { /// A menu item was chosen. An engine command whose label ends in "…" and that takes parameters /// opens a dialog built from its parameter documentation (see [`crate::dialogs::command_fields`]). pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { + let was_find = app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); + activate_inner(app, id, params); + if was_find && app.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { + app.session.reset_find_scope(); + } +} + +fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { if params.is_null() && id == "file.close" { close_document(app, None); return; From 923479bd053bab3f99d47c41830983748d16aad1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 20:00:37 +0000 Subject: [PATCH 032/128] Integration: #610 a linked text file with a future time is remembered once instead of read on every poll --- crates/engine/src/cmd/links.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a28d49e73..45ce33956 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -156,12 +156,14 @@ pub(crate) fn checkable_size(len: usize) -> bool { } /// Can a size and time be trusted to change when the file does? Times are whole seconds, so not -/// while the file was changed in the last two: another change in that second keeps the time. +/// while the file was changed in the last two: another change in that second keeps the time. A +/// time in the future (clock skew, a file copied from another machine) counts as settled, or such +/// a file would be read on every poll; a later save gives it a time unlike the remembered one. pub(crate) fn settled(f: &Fingerprint) -> bool { #[cfg(not(target_arch = "wasm32"))] { let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)); - now.saturating_sub(f.mtime) >= 2 + f.mtime > now || now.saturating_sub(f.mtime) >= 2 } #[cfg(target_arch = "wasm32")] { @@ -794,6 +796,13 @@ mod text_link_tests { s.execute("links.update", &json!({})).unwrap(); readable(false); assert_eq!(status(&mut s), "ok"); + // A time in the future (clock skew): read once, then not on every poll. + readable(true); + let f = std::fs::File::options().write(true).open(&path).unwrap(); + f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(3600)).unwrap(); + assert_eq!(status(&mut s), "ok"); + readable(false); + assert_eq!(status(&mut s), "ok", "a future time is remembered"); readable(true); let _ = std::fs::remove_dir_all(&dir); } From 06ed9bb4f547b2752837d9eae111fe865e8ce9be Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 19:37:52 +0000 Subject: [PATCH 033/128] Integration: #588 CMYK ink passes cull items to the area they cover (cherry picked from commit b3551e3ce62f8f4e080c694632349e80decca93c) --- crates/render/src/blendspace.rs | 24 +++++++++++++++++++++++- crates/render/src/lib.rs | 5 +++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 020122572..dffde947c 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -362,13 +362,21 @@ impl Renderer { // Inside it: the two ink passes, with the render threads, over just the area. let (aw, ah) = (area.width() as u16, area.height() as u16); let shifted = Affine::translate((-area.x0, -area.y0)) * f.view; + // The passes draw only what reaches the area: items elsewhere are culled. + let visible = f.view.inverse().transform_rect_bbox(area); let stats = self.stats; let threads = self.threads; let mut pass = |plate| { let opts = RenderOptions { plate: Some(plate), ..rgb.clone() }; crate::PLATE.with(|p| p.set(opts.plate)); let mut off = RenderContext::new_with(aw, ah, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }); - self.draw_spread(&mut off, &Frame { opts: &opts, view: shifted, mt: threads > 0, ..*f }, sr); + #[cfg(test)] + let items = self.stats.items; + self.draw_spread(&mut off, &Frame { opts: &opts, view: shifted, visible, mt: threads > 0, ..*f }, sr); + #[cfg(test)] + { + self.cmyk_pass_items = self.stats.items - items; + } off.flush(); let mut pm = vello_cpu::Pixmap::new(aw, ah); off.render(&mut pm, &mut self.resources); @@ -585,6 +593,20 @@ mod tests { assert!(renderer.cmyk_pass_pixels >= 75 * 45, "{} px", renderer.cmyk_pass_pixels); } + #[test] + fn ink_passes_draw_only_the_items_that_reach_their_area() { + // A small Multiply object in a corner and a solid one far from it: the ink passes cover the + // corner and draw just the Multiply object. + let mut doc = document(); + rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(100.0, 70.0, 150.0, 110.0), "C=100 M=0 Y=0 K=0", DcBlend::Normal); + rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(5.0, 5.0, 25.0, 25.0), "C=0 M=100 Y=0 K=0", DcBlend::Multiply); + let mut renderer = Renderer::new(); + renderer.threads = 0; + draw(&doc, &mut renderer); + assert_eq!(renderer.cmyk_passes, 2); + assert_eq!(renderer.cmyk_pass_items, 1); + } + #[test] fn the_view_is_kept_only_when_the_ink_passes_ran() { let mut doc = document(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c9e4c17f0..5f0a2b972 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -227,6 +227,9 @@ pub struct Renderer { /// Pixels the last ink pass covered (tests). #[cfg(test)] cmyk_pass_pixels: usize, + /// Items the last ink pass drew (tests). + #[cfg(test)] + cmyk_pass_items: usize, pub threads: u16, pub stats: FrameStats, } @@ -278,6 +281,8 @@ impl Renderer { cmyk_passes: 0, #[cfg(test)] cmyk_pass_pixels: 0, + #[cfg(test)] + cmyk_pass_items: 0, threads: default_threads(), stats: FrameStats::default(), } From 57c997634fe42cf2e1a6f39daae332642cfba9bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 19:33:08 +0000 Subject: [PATCH 034/128] Integration: #825 PDF table-cell and footnote text lays a gradient across its own lines, as on screen (cherry picked from commit 14e98419ad7afd052a51de9c61eb4dd6c05462eb) --- crates/engine/src/cmd/text.rs | 71 +++++++++++++++++++++++++++++++++++ crates/pdf/src/text.rs | 20 ++++++++++ 2 files changed, 91 insertions(+) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 889ba3ed6..3bfacb353 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2612,4 +2612,75 @@ mod gradient_text_tests { let d = p.doc().unwrap().doc.clone(); check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf"); } + + /// The columns the red → blue ink spans (black table edges left out). + fn ink_span(img: &designcraft_render::Rendered) -> (u32, u32) { + let cols: Vec = (0..img.width) + .filter(|&x| { + (0..img.height).any(|y| { + let p = img.pixel(x, y); + p[1] < 100 && p[0].max(p[2]) > 100 + }) + }) + .collect(); + (*cols.first().unwrap(), *cols.last().unwrap()) + } + + /// Mean red and blue of the red → blue ink in columns `x0..x1`. + fn coloured_ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { + let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height { + let p = img.pixel(x, y); + if p[1] < 100 && p[0].max(p[2]) > 100 { + (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); + } + } + } + assert!(n > 20.0, "no ink in {x0}..{x1}"); + (r / n, b / n) + } + + #[test] + fn a_gradient_on_table_cell_text_spans_the_cells_text_in_pdf_as_on_screen() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); + let g = s + .execute("swatch.create", &json!({"gradient": {"stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}})) + .unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap(); + // A third of the frame's width: the cell's text, red → blue. + let mut d: designcraft_doc::Document = (*s.doc().unwrap().doc).clone(); + let sid = d.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; + let t = std::sync::Arc::make_mut(d.story_mut(sid).unwrap().tables.values_mut().next().unwrap()); + let text = &mut t.cell_mut(0, 0).unwrap().text; + text.insert(0, "HHHH"); + text.format_chars(0..4, |f| { + f.over.fill = Some(g.clone()); + f.over.size = Some(24.0); + }); + let mut rr = designcraft_render::Renderer::new(); + let cache = designcraft_compose::Cache::new(); + let screen = rr.render_page(&d, &cache, 0, 1.0, false, &Default::default()).unwrap(); + let pdf = designcraft_pdf::export_pdf(&d, &cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "cell.pdf", "x": 0, "y": 0, "width": 300})) + .unwrap(); + let placed = rr.render_page(&p.doc().unwrap().doc.clone(), &p.cache, 0, 1.0, false, &Default::default()).unwrap(); + let (x0, x1) = ink_span(&screen); + assert!(x1 < 120, "the text stays in the first column: {x0}..{x1}"); + let q = (x1 - x0) / 4; + for (what, img) in [("screen", &screen), ("pdf", &placed)] { + let (lr, lb) = coloured_ink(img, x0, x0 + q); + let (rr, rb) = coloured_ink(img, x1 + 1 - q, x1 + 1); + assert!(lr > lb + 50.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); + assert!(rb > rr + 50.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); + } + } } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index b7921f326..a6695d0b7 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -35,6 +35,22 @@ impl Exporter<'_> { } } +/// The box a gradient on a cell's or a note's text spans: its lines, each padded by its ascent as +/// the screen renderer pads them, so both lay the gradient out alike. +fn lines_area(ft: &FrameText) -> Option { + ft.lines + .iter() + .filter_map(|l| { + let first = l.glyphs.first()?; + let x0 = l.glyphs.iter().map(|g| g.x).fold(first.x, f64::min); + let x1 = l.glyphs.iter().map(|g| g.x + g.adv.max(0.0)).fold(l.end_x, f64::max); + let pad = l.ascent.max(1.0); + let bottom = l.drop_cap.map_or(l.baseline + l.descent.max(0.0), |dc| dc.rect.y1.max(l.baseline + l.descent.max(0.0))); + Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, bottom + pad)) + }) + .reduce(|a, b| a.union(b)) +} + /// An opaque nonzero fill with `paint`. fn paint_fill(paint: Paint) -> Fill { Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero } @@ -195,7 +211,9 @@ impl Exporter<'_> { for n in &ft.notes { if let Some(nft) = n.text.frames.first() { s.push_transform(&tf(Affine::translate(n.origin.to_vec2()))); + let area = std::mem::replace(&mut me.text_area, lines_area(nft)); me.frame_text(s, &n.text, nft, &n.source); + me.text_area = area; s.pop(); } } @@ -223,7 +241,9 @@ impl Exporter<'_> { } } else if let Some(cft) = c.text.frames.first() { s.push_transform(&tf(Affine::translate(c.origin.to_vec2()))); + let area = std::mem::replace(&mut self.text_area, lines_area(cft)); self.frame_text(s, &c.text, cft, &c.source); + self.text_area = area; s.pop(); } } From 87682c36747937501ef8bd8e1e2c19b6f49e2d6f Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 22:33:46 +0000 Subject: [PATCH 035/128] Integration: #836 a grey stop beside a CMYK stop exports as the K ink, keeping the PDF shading in DeviceCMYK (cherry picked from commit f28456d9d33930fcdd48b1c74460d72fe47a6d77) --- crates/color/src/lib.rs | 1 - crates/pdf/src/export.rs | 10 +++++++++- crates/pdf/tests/gradient_color_spaces.rs | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 73a8cb269..24c2798a2 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -169,7 +169,6 @@ impl Color { let [r, g, b] = self.to_rgb(); Color::rgb(1.0 - r, 1.0 - g, 1.0 - b) } - /// Linear interpolation in display RGB. /// The colour `t` of the way to `other`. Process inks blend as inks (CMYK with CMYK or grey, /// grey with grey), as InDesign blends a gradient's stops; anything else blends in RGB. pub fn lerp(&self, other: &Color, t: f32) -> Color { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..d66ad6438 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -981,7 +981,15 @@ impl Exporter<'_> { let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { let kind = g.kind; - let expanded = g.expanded_stops(); + let mut expanded = g.expanded_stops(); + // Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink. + if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) { + for (_, c, _) in &mut expanded { + if let Color::Gray { k } = *c { + *c = Color::cmyk(0.0, 0.0, 0.0, k); + } + } + } // A PDF shading needs one colour space. Midpoint expansion can introduce RGB even // when every authored stop is CMYK/Gray; PDF/A can also change the output space. let components = |c: &Color| match c { diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs index f9892965b..05806a120 100644 --- a/crates/pdf/tests/gradient_color_spaces.rs +++ b/crates/pdf/tests/gradient_color_spaces.rs @@ -53,7 +53,7 @@ fn mixed_gradient_export_survives_in_subprocess() { let rgb = Color::rgb(1.0, 0.0, 0.0); let cmyk = Color::cmyk(1.0, 0.0, 0.0, 0.0); let gray = Color::gray(0.4); - for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, cmyk], vec![gray, rgb, cmyk]] { + for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, rgb, cmyk]] { for kind in [GradientKind::Linear, GradientKind::Radial] { for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { let d = document(&colors, kind, 0.5, 0.7); @@ -107,6 +107,8 @@ fn homogeneous_gradients_keep_device_spaces_and_components() { ([Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)], "DeviceRGB", vec![1.0, 0.0, 0.0], vec![0.0, 0.0, 1.0]), ([Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)], "DeviceCMYK", vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]), ([Color::gray(0.2), Color::gray(0.8)], "DeviceGray", vec![0.8], vec![0.2]), + // Grey beside CMYK is the K ink, so the shading stays CMYK. + ([Color::gray(0.4), Color::cmyk(1.0, 0.0, 0.0, 0.0)], "DeviceCMYK", vec![0.0, 0.0, 0.0, 0.4], vec![1.0, 0.0, 0.0, 0.0]), ] { for standard in [Standard::None, Standard::PdfX4] { let report = export_pdf_with_report( From bf22391581486e8d26f8b13782ab9a134c2f0ed5 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 09:33:07 +0200 Subject: [PATCH 036/128] Gradient text test: make the cell's gradient swatch with object.gradient (swatch.create's gradient form is #763's) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3bfacb353..0c32a50aa 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2645,12 +2645,11 @@ mod gradient_text_tests { fn a_gradient_on_table_cell_text_spans_the_cells_text_in_pdf_as_on_screen() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); - let g = s - .execute("swatch.create", &json!({"gradient": {"stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}})) - .unwrap()["name"] - .as_str() - .unwrap() - .to_string(); + // A gradient swatch (an off-page object's unnamed one), red → blue. + let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})) + .unwrap(); + let g = s.doc().unwrap().doc.item(designcraft_doc::ItemId(shape.as_u64().unwrap())).unwrap().fill.swatch.clone(); let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": ""})).unwrap(); s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap(); From 7d601b5903a59f05204594c47c9543162142d938 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 01:39:08 +0000 Subject: [PATCH 037/128] Integration: #864 drops the Controlled by Parent rule from the menus and the Objects Move with Page checkbox (neither has an effect yet) (cherry picked from commit 47b51d637756a468555feeac60965d3c94ab05d1) --- ROADMAP.md | 2 +- crates/ui-egui/src/chrome.rs | 13 ++++++------- crates/ui-egui/src/i18n.rs | 10 ---------- crates/ui-egui/src/i18n/ar.rs | 1 - crates/ui-egui/src/i18n/it.rs | 1 - crates/ui-egui/src/i18n/ja.rs | 1 - crates/ui-egui/src/i18n/pt_br.rs | 1 - crates/ui-egui/src/i18n/uk.rs | 1 - crates/ui-egui/src/panels/interactive.rs | 3 +-- docs/target-app-parity.md | 2 +- 10 files changed, 9 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 573b0a37b..8ac26c2a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,7 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. +- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f3a17ebf9..44a970afb 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -195,7 +195,7 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { } /// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size -/// preset, orientation, Liquid Page Rule, Objects Move with Page and Show Parent Page Overlay, for +/// preset, orientation, Liquid Page Rule and Show Parent Page Overlay, for /// the page the Page tool selected (else the page in view). W/H keep the reference point in place. fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { let lang = app.ui.language.clone(); @@ -274,9 +274,6 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { }); vsep(ui); ui.vertical(|ui| { - // Objects follow a page that moves in its spread; pages don't move yet, so it's always on. - let mut follow = true; - ui.add_enabled(false, egui::Checkbox::new(&mut follow, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Objects Move with Page")))); let mut overlay = pg.show_parent_overlay; if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() { let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay})); @@ -930,9 +927,11 @@ mod tests { app.run("file.new", serde_json::json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); app.session.set_tool("page"); let mut h = test_window::open(app, vec2(1800.0, 600.0)); - for label in ["Liquid Page Rule:", "Objects Move with Page"] { - let _ = h.get_by_label(label); - } + let _ = h.get_by_label("Liquid Page Rule:"); + // Pages don't move within their spread, so there is no "Objects Move with Page" option, and + // no "Controlled by Parent" rule until parent pages have rules of their own. + assert!(h.query_by_label("Objects Move with Page").is_none()); + assert!(!crate::panels::interactive::LIQUID_RULES.iter().any(|(r, _)| *r == designcraft_doc::LiquidRule::ControlledByParent)); assert_eq!(crate::panels::properties::preset_name(252.0, 144.0), "US Business Card"); h.get_by_label("Show Parent Page Overlay").click(); h.run_steps(4); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5dc452a2a..1375093da 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2523,16 +2523,6 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Liquid Page Rule:", ["Regel für flüssige Seiten:", "Règle de page liquide :", "Regla de página líquida:", "リキッドページルール:", "自适应页面规则:"], ), - ( - "Objects Move with Page", - [ - "Objekte mit Seite verschieben", - "Déplacer les objets avec la page", - "Los objetos se mueven con la página", - "オブジェクトをページと共に移動", - "对象随页面移动", - ], - ), ( "Show Parent Page Overlay", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 66ca5390a..1daa0101d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -722,7 +722,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), ("Liquid Page Rule:", "قاعدة الصفحة المرنة:"), - ("Objects Move with Page", "تحريك الكائنات مع الصفحة"), ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), ("US Business Card", "بطاقة عمل أمريكية"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index aa630d3c9..d9435a316 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1300,7 +1300,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "Basato su guide"), ("Resize Page", "Ridimensiona pagina"), ("Liquid Page Rule:", "Regola pagina liquida:"), - ("Objects Move with Page", "Sposta oggetti con la pagina"), ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), ("Controlled by Parent", "Controllato dalla pagina principale"), ("US Business Card", "Biglietto da visita USA"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 88e930479..f35d4084a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1296,7 +1296,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), ("Liquid Page Rule:", "リキッドページルール:"), - ("Objects Move with Page", "オブジェクトをページと共に移動"), ("Show Parent Page Overlay", "親ページオーバーレイを表示"), ("Controlled by Parent", "親ページで制御"), ("US Business Card", "US 名刺"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 0005cc07a..6a1673503 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -713,7 +713,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "Baseado em guias"), ("Resize Page", "Redimensionar página"), ("Liquid Page Rule:", "Regra de página líquida:"), - ("Objects Move with Page", "Objetos se movem com a página"), ("Show Parent Page Overlay", "Mostrar sobreposição da página principal"), ("Controlled by Parent", "Controlado pela página principal"), ("US Business Card", "Cartão de visita EUA"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 32ac4864f..5e95517ca 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1297,7 +1297,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), ("Liquid Page Rule:", "Правило гнучкої сторінки:"), - ("Objects Move with Page", "Об’єкти рухаються зі сторінкою"), ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), ("Controlled by Parent", "Керується батьківською сторінкою"), ("US Business Card", "Візитна картка (США)"), diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index cd36926fa..876fc23b5 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -487,13 +487,12 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { } /// Liquid page rules in InDesign's order, with their menu labels. -pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 6] = [ +pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 5] = [ (designcraft_doc::LiquidRule::Off, "Off"), (designcraft_doc::LiquidRule::Scale, "Scale"), (designcraft_doc::LiquidRule::ReCenter, "Re-center"), (designcraft_doc::LiquidRule::ObjectBased, "Object-based"), (designcraft_doc::LiquidRule::GuideBased, "Guide-based"), - (designcraft_doc::LiquidRule::ControlledByParent, "Controlled by Parent"), ]; /// A liquid page rule's menu label. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 95c9fd0ad..5011b3450 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -398,7 +398,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule, Show Parent Page Overlay); X/Y are shown, but pages don't move within their spread yet. - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From f4efb7cc5cd363454141d108f184784e526b8cd5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 01:38:39 +0000 Subject: [PATCH 038/128] Integration: #869 Alt-drag copies a page in one command and one undo step (layout.pages.duplicateSpread takes to) (cherry picked from commit 7889b25a379fdf27dd128bc49512bc3c0cbfcbfd) --- crates/engine/src/cmd/layout.rs | 23 +++++++++++++++++++++-- crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/pages.rs | 24 ++++++++++++------------ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 663e10791..7a6b05054 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -140,13 +140,18 @@ pub fn specs() -> Vec { "Duplicate Spread", ["Layout", "Pages"], None, - "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", + "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there), to?: page index the copied pages move to, in the same undo step (the Pages panel's Alt-drag)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", has_doc, |s, p| { let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); + let to = p.get("to").and_then(Value::as_u64).map(|t| t as usize); s.edit(|d, _| { let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; + // The copies land at `to`..`to + n` of the document with them in it. + if to.is_some_and(|t| t > d.page_count()) { + return Err(bad("layout.pages.duplicateSpread", "no such page")); + } let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; let parent = sp.pages.first().and_then(|p| p.parent); d.insert_pages(Some(after), sp.pages.len(), parent)?; @@ -169,7 +174,21 @@ pub fn specs() -> Vec { let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; - Ok(json!({"page": after + 1})) + let first = after + 1; + let Some(to) = to else { return Ok(json!({"page": first})) }; + // Moving a page shifts the ones between; order the moves so each copy keeps + // its place among the others. + let n = sp.pages.len(); + if first < to { + for k in (0..n).rev() { + d.move_page(first + k, to + k)?; + } + } else { + for k in 0..n { + d.move_page(first + k, to + k)?; + } + } + Ok(json!({"page": to})) }) } ), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0e7a011f7..962a0f69d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -695,3 +695,31 @@ fn type_tool_frames_snap_to_the_margins() { assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}"); assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1); } + +#[test] +fn duplicating_a_page_to_an_index_is_one_undo_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": false})).unwrap(); + s.execute("layout.pages.insert", &json!({"count": 2})).unwrap(); + for (page, width) in [(1, 300), (2, 400), (3, 500)] { + s.execute("layout.pageSize", &json!({"pages": [page], "width": width, "height": 600})).unwrap(); + } + let widths = |s: &Session| { + let d = &s.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page_loc(i).map(|(si, pi)| d.spreads[si].pages[pi].width)).collect::>>().unwrap() + }; + // Backwards: the last page copied to the front. + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 2, "to": 0})).unwrap(); + assert_eq!(r["page"], 0); + assert_eq!(widths(&s), [500.0, 300.0, 400.0, 500.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); + // Forwards: the first page copied to the end. + s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 3})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0, 300.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); + // Past the end: refused, nothing changes. + assert!(s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 4})).is_err()); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); +} diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d70b3427e..a9e4c65cc 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -239,21 +239,21 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); }); - // Alt-drag (InDesign's Option-drag) copies the page to where it's dropped. - let copy = ui.input(|i| i.modifiers.alt); + // Alt-drag copies a page that is a spread of its own to where it's dropped, in one undo + // step; a page from a larger spread moves as usual. + let alt = ui.input(|i| i.modifiers.alt); + let copy = |from: usize| { + let (si, _) = doc.page_loc(from)?; + (alt && doc.spreads.get(si).is_some_and(|s| s.pages.len() == 1)).then_some(si) + }; match drop { - Some((PageDrag::Page(from), to)) if copy => { - if let Some((si, _)) = doc.page_loc(from) - && let Ok(r) = app.run("layout.pages.duplicateSpread", json!({"spread": si})) - && let Some(new) = r["page"].as_u64().map(|p| p as usize) - && new != to - { - let _ = app.run("layout.pages.move", json!({"from": new, "to": to})); + Some((PageDrag::Page(from), to)) => { + if let Some(si) = copy(from) { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "to": to})); + } else if from != to { + let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); } } - Some((PageDrag::Page(from), to)) if from != to => { - let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); - } Some((PageDrag::Parent(prefix), to)) => { let _ = app.run("layout.pages.applyParent", json!({"pages": [to], "parent": prefix})); } From 1758642a96da8d0db6cf00b6126511ab3224c189 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 01:39:48 +0000 Subject: [PATCH 039/128] Integration: #870 the page menu always offers Delete Page for the clicked page, Delete Spread separately for multi-page spreads (cherry picked from commit 9912a386a84baaf91f426482d7b9d88d3cd9994a) --- crates/ui-egui/src/panels/pages.rs | 41 ++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d708bfdee..ad8a8ab86 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -292,9 +292,13 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); ui.close(); } - if item(ui, if pages_in_spread > 1 { "Delete Spread" } else { "Delete Page" }) { + if item(ui, "Delete Page") { + let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + ui.close(); + } + if pages_in_spread > 1 && item(ui, "Delete Spread") { let first = doc.first_page_of_spread(si); - let _ = app.run("layout.pages.delete", json!({"pages": (first..first + pages_in_spread).collect::>()})); + let _ = app.run("layout.pages.delete", json!({"pages": (first..first.saturating_add(pages_in_spread)).collect::>()})); ui.close(); } ui.separator(); @@ -401,12 +405,10 @@ mod tests { h.run_steps(3); } - #[test] - fn the_panel_menu_follows_indesigns_order() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + /// The page menu for page `abs` of spread `si`. + fn menu_harness(app: DesignApp, abs: usize, si: usize) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( - |ui, app: &mut DesignApp| { + move |ui, app: &mut DesignApp| { let key = egui::Id::new("test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); @@ -414,11 +416,34 @@ mod tests { return; } let doc = app.session.doc().unwrap().doc.clone(); - page_menu(app, ui, &doc, 0, 0); + page_menu(app, ui, &doc, abs, si); }, app, ); h.run_steps(3); + h + } + + #[test] + fn a_page_in_a_spread_offers_delete_page_and_delete_spread() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 5, "facingPages": true})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, _) = doc.page_loc(2).unwrap(); + assert_eq!(doc.spreads[si].pages.len(), 2); + let mut h = menu_harness(app, 2, si); + let _ = h.get_by_label("Delete Spread"); + h.get_by_label("Delete Page").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 4, "only the clicked page"); + } + + #[test] + fn the_panel_menu_follows_indesigns_order() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + let mut h = menu_harness(app, 0, 0); + assert!(h.query_by_label("Delete Spread").is_none(), "a single-page spread has no Delete Spread"); // InDesign's items, top to bottom. let ys: Vec = ["Insert Pages…", "Move Pages…", "Duplicate Spread", "Duplicate Spread at End of Document", "Delete Page", "New Parent…"] .iter() From be98657a221dac9af5c540520eb78e4f9fdfe51f Mon Sep 17 00:00:00 2001 From: Andres Torresi Date: Wed, 14 Oct 2026 08:42:28 +0100 Subject: [PATCH 040/128] feat(text): Typography panel lists and sets the OpenType features of the selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Typography panel (Window ▸ Show Panel, and the icon column) lists the features of the selected text's fonts, merged over those fonts, grouped as Ligatures, Alternates, Stylistic Sets (named by the font), Figure Style, Figure Width, Figure Position and Capitals. A section or choice appears only when a font offers it. Each feature is a checkbox: checked when all of the selection has it, a dash when only some of it does, empty when none does. A feature only some of the selection's fonts offer is italic. Mutually exclusive choices are checkboxes too, and choosing one turns the others off. type.openTypeFeatures reports each feature's applied and available state over the selection. type.feature turns features on or off across the selection, keeping each run's other features. The fonts crate reads GSUB and GPOS feature lists and stylistic set names. The Character panel's OpenType menu is now a button that opens or focuses the Typography panel. Co-Authored-By: Claude Haiku 5.5 --- crates/doc/src/otf.rs | 2 +- crates/engine/src/cmd/text.rs | 172 ++++++++++++++++++ crates/fonts/src/features.rs | 52 ++++++ crates/fonts/src/lib.rs | 2 + crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/properties.rs | 48 ++--- crates/ui-egui/src/panels/typography.rs | 225 ++++++++++++++++++++++++ 8 files changed, 470 insertions(+), 34 deletions(-) create mode 100644 crates/fonts/src/features.rs create mode 100644 crates/ui-egui/src/panels/typography.rs diff --git a/crates/doc/src/otf.rs b/crates/doc/src/otf.rs index e2eb9408b..5e246b8bf 100644 --- a/crates/doc/src/otf.rs +++ b/crates/doc/src/otf.rs @@ -31,7 +31,7 @@ pub const FIGURES: &[(&str, &str, &[&str], &str)] = &[ /// Features shaping turns on by itself: "off" is an explicit `-tag`. fn default_on(tag: &str) -> bool { - matches!(tag, "calt" | "locl" | "mark") + matches!(tag, "calt" | "liga" | "locl" | "mark") } pub fn is_on(list: &[String], tag: &str) -> bool { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0b87820ff..724354c02 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -292,6 +292,16 @@ pub fn specs() -> Vec { break_link ), cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs), + cmd!(query "type.openTypeFeatures", "OpenType Features", [], None, "{} → the OpenType features and stylistic sets the selected text's fonts offer, each with its applied and available state (none, some or all of the selection)", has_doc, open_type_features), + cmd!( + "type.feature", + "Set OpenType Features", + [], + None, + "{features: {tag: true|false, …}} — turns OpenType features on or off across the selected text, keeping each run's other features", + has_doc, + set_features + ), ] } @@ -1378,6 +1388,125 @@ fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result { Ok(json!({"on": on, "changed": changed})) } +/// One stretch of the selection: its length in bytes, the font it is set in and its features. +struct FeatureSegment { + len: usize, + font: (String, String), + applied: Vec, +} + +/// The OpenType features of the fonts in the selection, with how much of the selection each one is +/// applied to and how much of it the fonts offer. A caret counts as one byte. `state` is `none`, +/// `some` or `all`. +fn open_type_features(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let mut segments: Vec = Vec::new(); + for Target { story: sid, cell, range: r } in format_targets(s) { + let Some(story) = st.doc.text_story(sid, cell) else { continue }; + let covered: Vec<(usize, usize, designcraft_doc::story::CharFormat)> = if r.is_empty() { + vec![(r.start, 1, story.format_after(r.start).clone())] + } else { + story + .runs() + .filter(|(run, _)| run.start < r.end && run.end > r.start) + .map(|(run, f)| (run.start, run.end.min(r.end).saturating_sub(run.start.max(r.start)), f.clone())) + .collect() + }; + for (pos, len, cf) in covered { + let Some(para) = story.paras.get(story.para_at(pos)) else { continue }; + let (_, base) = st.doc.styles.resolve_para(para); + let cp = st.doc.styles.resolve_char(&base, &cf); + segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features }); + } + } + if segments.is_empty() { + let (_, base) = st.doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH); + let cp = st.doc.styles.resolve_char(&base, &designcraft_doc::story::CharFormat::default()); + segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features }); + } + let db = designcraft_fonts::FontDb::global(); + let mut fonts: std::collections::BTreeMap<(String, String), designcraft_fonts::FontFeatures> = std::collections::BTreeMap::new(); + for seg in &segments { + fonts.entry(seg.font.clone()).or_insert_with(|| designcraft_fonts::font_features(&db.face(&seg.font.0, &seg.font.1))); + } + let total: usize = segments.iter().map(|seg| seg.len).sum::().max(1); + let state = |covered: usize| match covered { + 0 => "none", + n if n >= total => "all", + _ => "some", + }; + // Bytes the feature is applied to, and bytes whose font offers it. + let measure = |tag: &str| { + let (mut applied, mut available) = (0, 0); + for seg in &segments { + if designcraft_doc::otf::is_on(&seg.applied, tag) { + applied += seg.len; + } + if fonts.get(&seg.font).is_some_and(|f| f.tags.contains(tag)) { + available += seg.len; + } + } + (state(applied), state(available)) + }; + let tags: std::collections::BTreeSet = fonts.values().flat_map(|f| f.tags.iter().cloned()).collect(); + let mut features = Vec::new(); + for tag in tags.iter().filter(|t| !(t.len() == 4 && t.starts_with("ss") && t[2..].parse::().is_ok())) { + let (applied, available) = measure(tag); + features.push(json!({"tag": tag, "applied": applied, "available": available})); + } + // A stylistic set's name is shared when every font that offers it gives it the same name. + let mut sets = Vec::new(); + for set in 1..=20u32 { + let tag = format!("ss{set:02}"); + if !tags.contains(&tag) { + continue; + } + let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect(); + let name = if names.len() == 1 { names.first().map(|n| n.to_string()) } else { None }; + let (applied, available) = measure(&tag); + sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available})); + } + let used: Vec = fonts.keys().map(|(family, style)| json!({"family": family, "style": style})).collect(); + Ok(json!({"fonts": used, "features": features, "stylisticSets": sets})) +} + +/// Turns OpenType features on or off across the selected text: `features` maps tags to whether they +/// are on. Each run keeps its other features, whichever font it is set in, and the change is one +/// undo step. +fn set_features(s: &mut Session, p: &Value) -> Result { + const ID: &str = "type.feature"; + let changes: Vec<(String, bool)> = p + .get("features") + .and_then(Value::as_object) + .ok_or_else(|| bad(ID, "missing features: a map of tags to true or false"))? + .iter() + .map(|(tag, on)| Ok((tag.clone(), on.as_bool().ok_or_else(|| bad(ID, format!("`{tag}` needs true or false")))?))) + .collect::>()?; + let targets: Vec = format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); + if targets.is_empty() { + return Err(bad(ID, "select text")); + } + let styles = s.doc()?.doc.styles.clone(); + s.edit(|d, _| { + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let runs: Vec<(std::ops::Range, designcraft_doc::story::CharFormat)> = + st.runs().filter(|(run, _)| run.start < t.range.end && run.end > t.range.start).map(|(run, f)| (run.clone(), f.clone())).collect(); + for (run, format) in runs { + let Some(para) = st.paras.get(st.para_at(run.start)).cloned() else { continue }; + let (_, base) = styles.resolve_para(¶); + let mut list = styles.resolve_char(&base, &format).otf_features; + for (tag, on) in &changes { + designcraft_doc::otf::set(&mut list, tag, *on); + } + let range = run.start.max(t.range.start)..run.end.min(t.range.end); + st.format_chars(range, |f| f.over.otf_features = Some(list.clone())); + } + } + ok() + }) +} + /// Our own filler text (not Adobe's). pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \ et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \ @@ -2558,3 +2687,46 @@ mod sentence_selection_tests { assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); } } + +#[cfg(test)] +mod open_type_features_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn feature<'a>(v: &'a Value, tag: &str) -> &'a Value { + v["features"].as_array().unwrap().iter().find(|f| f["tag"] == tag).unwrap_or(&Value::Null) + } + + #[test] + fn the_selected_fonts_features_are_listed_with_their_state() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello fi"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(v["fonts"][0]["family"], "Source Serif 4", "{v}"); + assert_eq!(feature(&v, "liga")["applied"], "all", "liga is on by default: {v}"); + assert_eq!(feature(&v, "kern")["available"], "all", "{v}"); + } + + #[test] + fn a_feature_on_part_of_the_selection_is_some_and_on_all_of_it_is_all() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello world"})).unwrap(); + let story = r["story"].clone(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); + s.execute("type.feature", &json!({"features": {"lnum": true}})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "some", "{v}"); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "all", "{v}"); + s.execute("type.feature", &json!({"features": {"lnum": false}})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "none", "{v}"); + } +} diff --git a/crates/fonts/src/features.rs b/crates/fonts/src/features.rs new file mode 100644 index 000000000..22dc80dec --- /dev/null +++ b/crates/fonts/src/features.rs @@ -0,0 +1,52 @@ +//! The OpenType features a font offers (its GSUB and GPOS feature lists) and the names it gives +//! its stylistic sets, for the Typography panel to show only what the selected fonts can do. + +use std::collections::{BTreeMap, BTreeSet}; + +use skrifa::MetadataProvider; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::layout::FeatureParams; + +use crate::FontFace; + +/// What one font's layout tables offer. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FontFeatures { + /// The feature tags its GSUB and GPOS tables define, such as `liga` or `ss01`. + pub tags: BTreeSet, + /// The name each stylistic set (1 to 20) gives itself, from its feature parameters. + pub stylistic_sets: BTreeMap, +} + +/// The features `face` offers. A face without layout tables offers none. +pub fn font_features(face: &FontFace) -> FontFeatures { + let mut out = FontFeatures::default(); + let Some(font) = face.skrifa() else { return out }; + let mut lists = Vec::new(); + if let Ok(gsub) = font.gsub() + && let Ok(list) = gsub.feature_list() + { + lists.push(list); + } + if let Ok(gpos) = font.gpos() + && let Ok(list) = gpos.feature_list() + { + lists.push(list); + } + for list in &lists { + for record in list.feature_records() { + let tag = record.feature_tag().to_string(); + let set = tag.strip_prefix("ss").and_then(|n| n.parse::().ok()).filter(|n| (1..=20).contains(n)); + if let Some(set) = set + && !out.stylistic_sets.contains_key(&set) + && let Ok(feature) = record.feature(list.offset_data()) + && let Some(Ok(FeatureParams::StylisticSet(params))) = feature.feature_params() + && let Some(name) = font.localized_strings(params.ui_name_id()).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()) + { + out.stylistic_sets.insert(set, name); + } + out.tags.insert(tag); + } + } + out +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 5ff395df8..efecc8c46 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod features; mod fontdb; mod group; mod kind; @@ -14,6 +15,7 @@ mod kind; pub mod testing; mod vertical; +pub use features::{FontFeatures, font_features}; pub use fontdb::{ DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, system_font_dirs, diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0d581e992..e2ec45078 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -20,6 +20,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("paragraphStyles", "Paragraph Styles", "panel-pstyles"), ("characterStyles", "Character Styles", "panel-cstyles"), ("character", "Character", "panel-character"), + ("typography", "Typography", "panel-character"), ("glyphs", "Glyphs", "panel-character"), ("paragraph", "Paragraph", "panel-paragraph"), ("textWrap", "Text Wrap", "panel-wrap"), @@ -205,6 +206,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "characterStyles" => panels::styles::character(app, ui), "stroke" => panels::properties::stroke_panel(app, ui), "character" => panels::properties::character_panel(app, ui), + "typography" => panels::typography::show(app, ui), "glyphs" => panels::glyphs::show(app, ui), "paragraph" => panels::properties::paragraph_panel(app, ui), "textWrap" => panels::properties::wrap_panel(app, ui), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index baf9a1251..2d2f46989 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -12,6 +12,7 @@ pub mod properties; pub mod styles; pub mod swatches; pub mod table; +pub mod typography; use designcraft_doc::{Content, StrokeAlign, WrapMode}; use designcraft_geom::Rect; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 4e207c44f..8c74b62ae 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1415,9 +1415,15 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { { let _ = app.run("type.char", json!({"attrs": {"position": if sup { "normal" } else { "superscript" }}})); } - let features: Vec = - c["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - crate::menus::menu_button(ui, "OpenType", |ui| open_type_menu(app, ui, &features)); + if ui + .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "OpenType"))) + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Show the Typography panel")); + }) + .clicked() + { + show_typography_panel(app); + } }); // Underline / Strikethrough Options. for (key, title) in [("underline", "Underline Options"), ("strikethrough", "Strikethrough Options")] { @@ -1452,37 +1458,13 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// The Character panel's OpenType menu: feature toggles, figure styles and stylistic sets. -pub fn open_type_menu(app: &mut DesignApp, ui: &mut egui::Ui, features: &[String]) { - use designcraft_doc::otf; - ui.set_min_width(220.0); - for (tag, label, _) in otf::TOGGLES { - let mut on = otf::is_on(features, tag); - if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run("type.openType", json!({"feature": tag, "on": on})); - ui.close(); - } +/// Opens the Typography panel, or focuses it when it is already showing. `window.panel` toggles a +/// flyout, so it is skipped while the Typography flyout is open. +pub fn show_typography_panel(app: &mut DesignApp) { + if app.ui.open_panel.as_deref() == Some("typography") { + return; } - ui.separator(); - let fig = otf::figures(features); - for (id, label, ..) in otf::FIGURES { - if ui.radio(fig == *id, *label).clicked() { - let _ = app.run("type.openType", json!({"figures": id})); - ui.close(); - } - } - ui.separator(); - crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stylistic Sets")), |ui| { - let mask = otf::stylistic_sets(features); - for n in 1..=20u32 { - let mut on = mask & (1 << (n - 1)) != 0; - if ui.checkbox(&mut on, crate::rtl::widget(ui, format!("{} {n}", crate::i18n::tr(&app.ui.language, "Set")))).clicked() { - let sets: Vec = (1..=20).filter(|k| if *k == n { on } else { mask & (1 << (k - 1)) != 0 }).collect(); - let _ = app.run("type.openType", json!({"stylisticSets": sets})); - ui.close(); - } - } - }); + let _ = app.run("window.panel", json!({"panel": "typography"})); } pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs new file mode 100644 index 000000000..ef34a0110 --- /dev/null +++ b/crates/ui-egui/src/panels/typography.rs @@ -0,0 +1,225 @@ +//! The Typography panel: the OpenType features the fonts of the selected text offer, grouped as +//! Affinity's are. Every feature is a checkbox that shows how much of the selection it is applied to +//! (checked: all of it; a dash: some of it; empty: none). A feature only some of the selection's fonts +//! offer is italic. Changes apply to the selection through `type.feature`. + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; + +use crate::DesignApp; + +/// A feature's state over the selection: applied to none of it, some of it, or all of it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Covered { + None, + Some, + All, +} + +impl Covered { + fn parse(s: &str) -> Self { + match s { + "all" => Covered::All, + "some" => Covered::Some, + _ => Covered::None, + } + } +} + +/// A switch: its OpenType tag and menu label. +type Switch = (&'static str, &'static str); + +/// A group of mutually exclusive choices: each one's label and the tags it turns on. +type Choice = (&'static str, &'static [&'static str]); + +const LIGATURES: &[Switch] = + &[("liga", "Standard Ligatures"), ("dlig", "Discretionary Ligatures"), ("hlig", "Historical Ligatures"), ("clig", "Contextual Ligatures")]; + +const ALTERNATES: &[Switch] = &[ + ("calt", "Contextual Alternates"), + ("salt", "Stylistic Alternates"), + ("swsh", "Swash"), + ("titl", "Titling Alternates"), + ("hist", "Historical Forms"), + ("ornm", "Ornaments"), + ("zero", "Slashed Zero"), +]; + +const FIGURE_STYLE: &[Choice] = &[("Lining", &["lnum"]), ("Oldstyle", &["onum"])]; + +const FIGURE_WIDTH: &[Choice] = &[("Tabular", &["tnum"]), ("Proportional", &["pnum"])]; + +const FIGURE_POSITION: &[Choice] = &[ + ("Numerator", &["numr"]), + ("Denominator", &["dnom"]), + ("Superscript", &["sups"]), + ("Subscript", &["subs"]), + ("Scientific Inferiors", &["sinf"]), +]; + +const FRACTION_AND_ORDINAL: &[Switch] = &[("frac", "Fractions"), ("ordn", "Ordinal")]; + +const CAPITALS: &[Choice] = &[("Caps to Small Caps", &["c2sc"]), ("Small Caps", &["smcp"]), ("All Small Caps", &["c2sc", "smcp"])]; + +/// A feature the selection's fonts offer, and its state over the selection. +#[derive(Clone, Copy)] +struct Feature { + applied: Covered, + available: Covered, +} + +/// What the selection's fonts offer: each feature's state, and each stylistic set's name and state. +#[derive(Default)] +struct Offered { + features: BTreeMap, + sets: Vec, +} + +struct Set { + number: u32, + tag: String, + name: Option, + feature: Feature, +} + +impl Offered { + fn get(&self, tag: &str) -> Option { + self.features.get(tag).copied() + } + + /// A choice's state: all of its tags applied is all, none is none, otherwise some. Its + /// availability is the least of its tags'. None when no tag is offered. + fn choice(&self, tags: &[&str]) -> Option { + let found: Vec = tags.iter().filter_map(|t| self.get(t)).collect(); + if found.is_empty() { + return None; + } + let applied = if found.iter().all(|f| f.applied == Covered::All) { + Covered::All + } else if found.iter().all(|f| f.applied == Covered::None) { + Covered::None + } else { + Covered::Some + }; + let available = found.iter().map(|f| f.available).min().unwrap_or(Covered::None); + Some(Feature { applied, available }) + } +} + +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + if super::text_attrs(app).is_none() { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select text or a text frame.")); + return; + } + let offered = offered(app); + if offered.features.is_empty() && offered.sets.is_empty() { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "The selected fonts have no OpenType features.")); + return; + } + let lang = app.ui.language.clone(); + // Changes this frame: each feature tag to the state it is set to. + let mut changes: BTreeMap = BTreeMap::new(); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Typography")).strong()); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + section(ui, &lang, "Ligatures", |ui| switches(ui, &lang, &offered, LIGATURES, &mut changes)); + section(ui, &lang, "Alternates", |ui| switches(ui, &lang, &offered, ALTERNATES, &mut changes)); + section(ui, &lang, "Stylistic Sets", |ui| stylistic_sets(ui, &lang, &offered, &mut changes)); + section(ui, &lang, "Figure Style", |ui| choices(ui, &lang, &offered, FIGURE_STYLE, &mut changes)); + section(ui, &lang, "Figure Width", |ui| choices(ui, &lang, &offered, FIGURE_WIDTH, &mut changes)); + section(ui, &lang, "Figure Position", |ui| { + choices(ui, &lang, &offered, FIGURE_POSITION, &mut changes); + switches(ui, &lang, &offered, FRACTION_AND_ORDINAL, &mut changes); + }); + section(ui, &lang, "Capitals", |ui| choices(ui, &lang, &offered, CAPITALS, &mut changes)); + }); + if !changes.is_empty() { + let _ = app.run("type.feature", json!({"features": changes})); + } +} + +/// A collapsible section, open at first. Its body runs only when it has something to show. +fn section(ui: &mut egui::Ui, lang: &str, title: &str, body: impl FnOnce(&mut egui::Ui)) { + egui::CollapsingHeader::new(crate::i18n::tr(lang, title)).id_salt(title).default_open(true).show(ui, body); +} + +/// A checkbox for a feature's state: checked when all of the selection has it, a dash when some of +/// it does, italic when only some of the selection's fonts offer it. Returns the state to set when +/// clicked. +fn tri_checkbox(ui: &mut egui::Ui, text: &str, feature: Feature) -> Option { + let mut on = feature.applied == Covered::All; + let mut label = egui::RichText::new(text); + if feature.available == Covered::Some { + label = label.italics(); + } + let response = ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, label)).indeterminate(feature.applied == Covered::Some)); + response.clicked().then_some(feature.applied != Covered::All) +} + +/// On/off switches the fonts offer. +fn switches(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Switch], changes: &mut BTreeMap) { + for (tag, label) in items { + let Some(feature) = offered.get(tag) else { continue }; + if let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) { + changes.insert(tag.to_string(), on); + } + } +} + +/// Mutually exclusive choices, as checkboxes: choosing one turns the others off. Choosing a checked +/// one turns it off. +fn choices(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Choice], changes: &mut BTreeMap) { + for (i, (label, tags)) in items.iter().enumerate() { + let Some(feature) = offered.choice(tags) else { continue }; + let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) else { continue }; + for t in *tags { + changes.insert(t.to_string(), on); + } + if on { + for (j, (_, others)) in items.iter().enumerate() { + if j == i { + continue; + } + for t in others.iter().filter(|t| !tags.contains(t)) { + changes.insert(t.to_string(), false); + } + } + } + } +} + +/// Stylistic sets by the name their fonts give them, or the generic `Stylistic Set NN` when the +/// selection's fonts name them differently. +fn stylistic_sets(ui: &mut egui::Ui, lang: &str, offered: &Offered, changes: &mut BTreeMap) { + for set in &offered.sets { + let label = match &set.name { + Some(name) => format!("{name} ({})", set.tag), + None => format!("{} {:02}", crate::i18n::tr(lang, "Stylistic Set"), set.number), + }; + if let Some(on) = tri_checkbox(ui, &label, set.feature) { + changes.insert(set.tag.clone(), on); + } + } +} + +/// The features the selection's fonts offer, with their state over the selection. +fn offered(app: &mut DesignApp) -> Offered { + let v = app.session.execute("type.openTypeFeatures", &json!({})).unwrap_or(Value::Null); + let mut out = Offered::default(); + for f in v["features"].as_array().into_iter().flatten() { + let (Some(tag), Some(applied), Some(available)) = (f["tag"].as_str(), f["applied"].as_str(), f["available"].as_str()) else { continue }; + out.features.insert(tag.to_string(), Feature { applied: Covered::parse(applied), available: Covered::parse(available) }); + } + for s in v["stylisticSets"].as_array().into_iter().flatten() { + let (Some(number), Some(tag), Some(applied), Some(available)) = + (s["set"].as_u64(), s["tag"].as_str(), s["applied"].as_str(), s["available"].as_str()) + else { + continue; + }; + let Ok(number) = u32::try_from(number) else { continue }; + let feature = Feature { applied: Covered::parse(applied), available: Covered::parse(available) }; + out.features.insert(tag.to_string(), feature); + out.sets.push(Set { number, tag: tag.to_string(), name: s["name"].as_str().map(str::to_string), feature }); + } + out +} From fbfc1dca7620e33d992053f157a0bbde71efc923 Mon Sep 17 00:00:00 2001 From: Andres Torresi Date: Wed, 14 Oct 2026 08:44:07 +0100 Subject: [PATCH 041/128] feat(ui): the Typography panel has its own OT icon in the dock The icon column's Typography entry reused the Character icon. It now uses an "OT" glyph (panel-typography), and a test checks it is neither the placeholder nor the Character icon. Co-Authored-By: Claude Haiku 5.5 --- crates/ui-egui/src/dock.rs | 2 +- crates/ui-egui/src/icons.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index e2ec45078..9b19f2302 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -20,7 +20,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("paragraphStyles", "Paragraph Styles", "panel-pstyles"), ("characterStyles", "Character Styles", "panel-cstyles"), ("character", "Character", "panel-character"), - ("typography", "Typography", "panel-character"), + ("typography", "Typography", "panel-typography"), ("glyphs", "Glyphs", "panel-character"), ("paragraph", "Paragraph", "panel-paragraph"), ("textWrap", "Text Wrap", "panel-wrap"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c93464374..6182c4a92 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -301,6 +301,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { } "panel-effects" => pen.text(10.0, 10.0, "fx", 11.0), "panel-character" => pen.text(10.0, 10.5, "A", 15.0), + "panel-typography" => pen.text(10.0, 10.5, "OT", 11.5), "panel-paragraph" => pen.text(10.0, 10.5, "¶", 15.0), "panel-pstyles" => { pen.text(8.0, 10.0, "¶", 13.0); @@ -878,6 +879,7 @@ mod tests { /// What icon `name` paints into a 20×20 rect, as the painter's shape list. fn drawing(name: &str) -> String { let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { paint(ui.painter(), Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 20.0)), name, Color32::WHITE); }); @@ -897,4 +899,12 @@ mod tests { assert_ne!(drawn[0], drawn[2], "Shear draws like Free Transform"); assert_ne!(drawn[1], drawn[2], "Scale and Shear draw alike"); } + + #[test] + fn typography_panel_has_its_own_icon() { + let unknown = drawing("no-such-icon"); + let typography = drawing("panel-typography"); + assert_ne!(typography, unknown, "panel-typography falls back to the placeholder"); + assert_ne!(typography, drawing("panel-character"), "Typography draws the Character icon"); + } } From 23e1680e7d6c23d120ca347bf84c51eb7e682a18 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 10:27:38 +0200 Subject: [PATCH 042/128] feat(ui): Swatches panel header like InDesign's; swatches go to the fill or stroke in front LYH Studio episode 6 colours the "1969" headline (8:28) and the loop's stroke (9:15) from the Swatches panel, whose header holds InDesign's Fill/Stroke proxy, Formatting Affects Container/Text and the Tint field. - Header: a Fill/Stroke proxy showing the colours of what colour edits apply to (panels::color_target; a click brings the other to the front, as X does), the container/text buttons (app.formattingAffectsText) and the tint of the colour in front. - The Tint field sends only the tint, so each selected object (or run of text) keeps its own swatch: object.fill takes a tint without a swatch, and a stroke tint alone doesn't give a stroke-less object a stroke. - Clicking a swatch applies it to the fill or stroke in front (panels::apply_swatch, to the text when colours affect text); that row is highlighted. - Bottom row: New Color Group, New Swatch, Delete Swatch (the swatch last clicked). Rows and the proxy are named for accessibility (and tests). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 73 ++++++++- crates/ui-egui/src/panels/swatches.rs | 220 ++++++++++++++++++++++++-- docs/target-app-parity.md | 2 +- 4 files changed, 272 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..8cfb4a1b4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 5e9ed3912..82c58c50b 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -179,12 +179,35 @@ pub fn specs() -> Vec { ) } ), - cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| { - let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string(); - known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?; - let tint = f64_or(p, "tint", 1.0) as f32; - set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false) - }), + cmd!( + "object.fill", + "Fill", + [], + None, + "{swatch? (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?} — at least one; a tint alone keeps each object's own swatch (the Swatches panel's Tint field)", + has_selection, + |s, p| { + let sw = str_param(p, "swatch").map(str::to_string); + let tint = match p.get("tint") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad("object.fill", "`tint` is 0..1"))? as f32), + }; + match &sw { + Some(sw) => known_swatch(&s.doc()?.doc, sw, "object.fill", "swatch")?, + None if tint.is_none() => return Err(bad("object.fill", "missing swatch")), + None => {} + } + set_flag( + s, + p, + move |i| match &sw { + Some(sw) => i.fill = Fill { swatch: sw.clone(), tint: tint.unwrap_or(1.0), ..i.fill.clone() }, + None => i.fill.tint = tint.unwrap_or(i.fill.tint), + }, + false, + ) + } + ), cmd!( "object.swapFillStroke", "Swap Fill and Stroke", @@ -217,10 +240,15 @@ pub fn specs() -> Vec { } } let p2 = p.clone(); + // A tint alone (the Swatches panel's Tint field) recolours strokes, it doesn't add one. + let tint_only = p.as_object().is_some_and(|o| o.contains_key("tint") && o.keys().all(|k| k == "tint" || k == "ids")); set_flag( s, p, move |i| { + if tint_only && i.stroke.is_none() { + return; + } let mut st = if i.stroke.is_none() && p2.get("swatch").is_none() { Stroke::default() } else { i.stroke.clone() }; if let Some(v) = p2.get("swatch").and_then(Value::as_str) { st.swatch = v.into(); @@ -3327,3 +3355,36 @@ mod spread_target_tests { assert!(s.doc().unwrap().doc.spreads[0].items.is_empty()); } } + +#[cfg(test)] +mod fill_tint_tests { + use super::*; + + #[test] + fn a_tint_alone_keeps_each_objects_own_swatch() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [136, 36, 200, 100]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + s.execute("object.fill", &json!({"ids": [b], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + s.execute("object.fill", &json!({"ids": [a, b], "tint": 0.4})).unwrap(); + let fill_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().fill.clone(); + assert_eq!((fill_of(&s, a).swatch.as_str(), fill_of(&s, a).tint), ("C=100 M=0 Y=0 K=0", 0.4)); + assert_eq!((fill_of(&s, b).swatch.as_str(), fill_of(&s, b).tint), ("C=0 M=100 Y=0 K=0", 0.4)); + // A swatch without a tint is at 100 %, as before. + s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap(); + assert_eq!(fill_of(&s, a).tint, 1.0); + // Neither, or a tint out of range: refused. + assert!(s.execute("object.fill", &json!({"ids": [a]})).is_err()); + assert!(s.execute("object.fill", &json!({"ids": [a], "tint": 2})).is_err()); + assert!(s.execute("object.fill", &json!({"ids": [a], "tint": "x"})).is_err()); + // A stroke tint alone: stroked objects take it, an object without a stroke keeps none. + s.execute("object.stroke", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0", "weight": 2})).unwrap(); + s.execute("object.stroke", &json!({"ids": [b], "swatch": "[None]"})).unwrap(); + s.execute("object.stroke", &json!({"ids": [a, b], "tint": 0.5})).unwrap(); + let stroke_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().stroke.clone(); + assert_eq!((stroke_of(&s, a).swatch.as_str(), stroke_of(&s, a).tint, stroke_of(&s, a).weight), ("C=100 M=0 Y=0 K=0", 0.5, 2.0)); + assert!(stroke_of(&s, b).is_none()); + } +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 967ff38f4..aea4e4acc 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,16 +11,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); - let text = super::colors_affect_text(app); - let fill_cur = super::color_target(app).map(|t| t.0); - ui.horizontal(|ui| { - crate::rtl::label( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, if text { "Applies to text" } else { "Applies to fill" })) - .size(11.0) - .color(t.text_dim), - ); - }); + let target = super::color_target(app); + header(app, ui, &doc, target.as_ref()); + // The row of the colour in front of the proxy is highlighted. + let fill_cur = target.map(|(fill, _, stroke, _)| if app.ui.stroke_front { stroke } else { fill }); // Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in // colour groups are listed under their folder. let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect(); @@ -64,15 +58,25 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } ui.add_space(6.0); + // InDesign's bottom row: New Color Group, New Swatch, Delete Swatch (the one last clicked). + let picked: Option = ui.data(|d| d.get_temp(egui::Id::new(PICKED))).filter(|n: &String| doc.swatch(n).is_some_and(|w| !w.locked)); ui.horizontal(|ui| { - ui.label(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Right-click: stroke, groups")).size(10.5).color(t.text_disabled), - )); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let del = ui + .add_enabled_ui(picked.is_some(), |ui| icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Swatch"))) + .inner; + if del.clicked() + && let Some(name) = &picked + && app.run("swatch.delete", json!({"name": name})).is_ok() + { + ui.data_mut(|d| d.remove::(egui::Id::new(PICKED))); + } if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "New Swatch")).clicked() { let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); } + if folder_button(ui, crate::i18n::tr(&app.ui.language, "New Color Group")).clicked() { + let _ = app.run("swatch.newColorGroup", json!({})); + } crate::menus::menu_button(ui, "☰", |ui| { crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| { let Ok(l) = app.session.execute("ink.list", &json!({})) else { return }; @@ -114,8 +118,99 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); } -/// One Swatches panel row: chip, name, type; click applies to the fill (of the frames or their -/// text, see [`super::colors_affect_text`]), the context menu to the stroke, colour groups and delete. +/// Temp-data key of the swatch last clicked (Delete Swatch removes it). +const PICKED: &str = "swatches_picked"; + +/// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front), +/// Formatting Affects Container / Text, and the tint of the colour in front. `target` is +/// [`super::color_target`]: fill and stroke swatches and tints of the text or the first object. +fn header(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, target: Option<&(String, f32, String, f32)>) { + let t = Tokens::get(ui.ctx()); + let (fill_sw, stroke_sw) = target.map_or(("[None]", "[Black]"), |(f, _, s, _)| (f.as_str(), s.as_str())); + let text = super::colors_affect_text(app) || app.ui.formatting_affects_text; + let stroke_front = app.ui.stroke_front; + ui.add_space(3.0); + ui.horizontal(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(27.0, 27.0), Sense::click()); + let tip = crate::i18n::tr(&app.ui.language, if stroke_front { "Stroke" } else { "Fill" }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + let fill_r = egui::Rect::from_min_size(r.min + vec2(1.0, 2.0), vec2(16.0, 16.0)); + let stroke_r = egui::Rect::from_min_size(r.min + vec2(10.0, 10.0), vec2(16.0, 16.0)); + let paint = |stroke: bool| { + let p = ui.painter(); + if stroke { + // The stroke: a frame of its colour. + let (c, g) = crate::widgets::swatch_colors(doc, stroke_sw, 1.0); + crate::widgets::paint_chip(p, stroke_r, c, g); + p.rect_filled(stroke_r.shrink(4.0), 0.0, t.panel); + p.rect_stroke(stroke_r.shrink(4.0), 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + p.rect_stroke(stroke_r, 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + } else { + let (c, g) = crate::widgets::swatch_colors(doc, fill_sw, 1.0); + crate::widgets::paint_chip(p, fill_r, c, g); + p.rect_stroke(fill_r, 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + } + }; + // The one in front is drawn last. + paint(!stroke_front); + paint(stroke_front); + if resp + .on_hover_ui(|ui| { + crate::rtl::label(ui, tip); + }) + .clicked() + { + let _ = app.run("color.toggleFillStroke", json!({})); + } + ui.add_space(4.0); + for (k, (icon, tip)) in + [("format-container", "Formatting affects container (J)"), ("format-text", "Formatting affects text (J)")].into_iter().enumerate() + { + if icons::button(ui, icon, 20.0, text == (k == 1), crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("app.formattingAffectsText", json!({"on": k == 1})); + } + } + ui.add_space(6.0); + crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Tint:")); + let tint = target.map(|(_, ft, _, st)| f64::from(if stroke_front { *st } else { *ft }) * 100.0); + if let Some(v) = crate::widgets::number(ui, "swatches_tint", tint, "", 44.0, 0) { + let _ = set_tint(app, v); + } + crate::widgets::caption(ui, "%"); + }); +} + +/// The Tint field: `percent` of the colour in front of the proxy, for the text or each selected +/// object, which keep their own swatches. +fn set_tint(app: &mut DesignApp, percent: f64) -> Result { + let v = percent.clamp(0.0, 100.0) / 100.0; + let stroke = app.ui.stroke_front; + if super::colors_affect_text(app) { + app.run("type.char", json!({"attrs": {if stroke { "strokeTint" } else { "fillTint" }: v}})) + } else { + app.run(if stroke { "object.stroke" } else { "object.fill" }, json!({"tint": v})) + } +} + +/// A folder button (New Color Group), drawn like the colour groups' folders. +fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let f = egui::Rect::from_center_size(r.center(), vec2(14.0, 10.0)); + ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.icon), egui::StrokeKind::Inside); + ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.icon)); + resp.on_hover_ui(|ui| { + crate::rtl::label(ui, tip); + }) +} + +/// One Swatches panel row: chip, name, type; click applies it to the fill or stroke in front of +/// the proxy (of the frames or their text, see [`super::colors_affect_text`]), the context menu +/// to the stroke, colour groups and delete. fn swatch_row( app: &mut DesignApp, ui: &mut egui::Ui, @@ -126,6 +221,7 @@ fn swatch_row( ) { let t = Tokens::get(ui.ctx()); let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &sw.name)); if fill_cur == Some(sw.name.as_str()) { ui.painter().rect_filled(row, 0.0, t.row_selected); } else if resp.hovered() { @@ -154,7 +250,8 @@ fn swatch_row( } ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() { - let _ = super::apply_swatch(app, false, &sw.name, None); + ui.data_mut(|d| d.insert_temp(egui::Id::new(PICKED), sw.name.clone())); + let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply to Stroke"))).clicked() { @@ -573,3 +670,92 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } } + +#[cfg(test)] +mod swatches_header_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ) + } + + fn items(app: &DesignApp) -> Vec { + let st = app.session.doc().unwrap(); + st.selection.items.iter().map(|id| st.doc.item(*id).unwrap().clone()).collect() + } + + #[test] + fn swatches_go_to_the_fill_or_stroke_in_front_and_the_bottom_row_deletes_the_one_clicked() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap(); + app.run("swatch.create", json!({"name": "Brand", "color": "#108080"})).unwrap(); + let mut h = harness(app); + h.run_steps(3); + // Fill in front: a swatch is the fill. + h.get_by_label("C=0 M=100 Y=0 K=0").click(); + h.run_steps(2); + assert_eq!(items(h.state())[0].fill.swatch, "C=0 M=100 Y=0 K=0"); + // The proxy brings the stroke to the front; the next swatch is the stroke. + h.get_by_label("Fill").click(); + h.run_steps(2); + assert!(h.state().ui.stroke_front); + h.get_by_label("C=100 M=0 Y=0 K=0").click(); + h.run_steps(2); + let it = &items(h.state())[0]; + assert_eq!((it.fill.swatch.as_str(), it.stroke.swatch.as_str()), ("C=0 M=100 Y=0 K=0", "C=100 M=0 Y=0 K=0")); + // Formatting affects text, from the panel. + h.get_by_label("Formatting affects text (J)").click(); + h.run_steps(2); + assert!(h.state().ui.formatting_affects_text); + h.get_by_label("Formatting affects container (J)").click(); + h.run_steps(2); + assert!(!h.state().ui.formatting_affects_text); + // Delete Swatch removes the swatch last clicked. + h.get_by_label("Brand").click(); + h.run_steps(2); + h.get_by_label("Delete Swatch").click(); + h.run_steps(2); + assert!(h.state().session.doc().unwrap().doc.swatch("Brand").is_none()); + } + + #[test] + fn the_tint_field_keeps_each_objects_own_swatch() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [36, 36, 100, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [136, 36, 200, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + app.run("object.fill", json!({"ids": [b], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + app.run("object.stroke", json!({"ids": [a, b], "swatch": "[Black]"})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let mut h = harness(app); + h.run_steps(3); + // The field shows the first selected object's fill tint. + assert!(h.query_all_by_value("100").next().is_some()); + set_tint(h.state_mut(), 40.0).unwrap(); + h.run_steps(2); + assert!(h.query_all_by_value("40").next().is_some(), "the field follows"); + // The stroke in front: the strokes' tints. + h.state_mut().ui.stroke_front = true; + set_tint(h.state_mut(), 70.0).unwrap(); + assert!(items(h.state()).iter().all(|i| i.stroke.tint == 0.7)); + let it = items(h.state()); + assert_eq!((it[0].fill.swatch.as_str(), it[0].fill.tint), ("C=100 M=0 Y=0 K=0", 0.4)); + assert_eq!((it[1].fill.swatch.as_str(), it[1].fill.tint), ("C=0 M=100 Y=0 K=0", 0.4)); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index daaa3f44c..04499ff5a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. From f69601b0ffd09da60cc90aabb2b713cf78bbc584 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 10:36:45 +0200 Subject: [PATCH 043/128] A linear gradient across a straight line runs along its length, on screen and in PDF alike Review fix from aiongg (72d7ea2e, with the line tests of 6db40bdb), ported to this branch: Fill::linear_axis gives screen and PDF one gradient axis, and a box with no extent along the gradient's angle takes its longer side. Co-authored-by: aiongg Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/item.rs | 15 ++++++++++++ crates/engine/src/cmd/style.rs | 45 ++++++++++++++++++++++++++++++++++ crates/pdf/src/export.rs | 10 ++------ crates/render/src/lib.rs | 9 +++---- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 1dc045e08..593edd170 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -32,6 +32,21 @@ impl Default for Fill { } impl Fill { + /// A linear gradient's axis on an object whose bounds (in its own space) are `b`: the + /// Gradient Swatch tool's vector, or the box's extent along the gradient's angle through its + /// centre. A box with no extent along that angle (a straight line, across it) takes its longer + /// side. + pub fn linear_axis(&self, b: Rect) -> (Point, Point) { + if let Some([x0, y0, x1, y1]) = self.gradient_vector { + return (Point::new(x0, y0), Point::new(x1, y1)); + } + let a = self.gradient_angle.unwrap_or(0.0).to_radians(); + let half = (b.width() * a.cos().abs() + b.height() * a.sin().abs()) / 2.0; + let half = if half > 1e-9 { half } else { b.width().max(b.height()).max(1e-3) / 2.0 }; + let d = designcraft_geom::Vec2::new(a.cos(), -a.sin()) * half; + (b.center() - d, b.center() + d) + } + pub fn none() -> Self { Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 08cb736a9..fdc11e805 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1900,6 +1900,51 @@ mod gradient_stroke_tests { assert!(a > 215 && b < 40, "dragged: {a} {b}"); assert!(s.execute("object.gradient", &json!({"ids": [id], "target": "gap"})).is_err()); } + + /// A 20 pt horizontal line with a gradient on its stroke, drawn on screen and from its PDF. + fn line_with_stroke_gradient(g: serde_json::Value) -> (designcraft_render::Rendered, designcraft_render::Rendered) { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); + let mut g = g; + g["ids"] = json!([id]); + g["target"] = json!("stroke"); + s.execute("object.gradient", &g).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + let screen = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "line.pdf", "x": 0, "y": 0, "width": 200})).unwrap(); + let pd = p.doc().unwrap().doc.clone(); + let placed = rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap(); + (screen, placed) + } + + #[test] + fn a_radial_gradient_on_a_straight_lines_stroke_shows_all_its_stops() { + // The line's box has no height: the gradient is a circle across the line's length. + let (screen, pdf) = line_with_stroke_gradient( + json!({"kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ); + for (what, img) in [("screen", &screen), ("pdf", &pdf)] { + let (a, m, b) = along(img); + assert!(m < 40 && a > 180 && b > 180, "{what}: {a} {m} {b}"); + } + } + + #[test] + fn a_linear_gradient_across_a_straight_line_paints_its_stroke_alike_on_screen_and_in_pdf() { + let (screen, pdf) = + line_with_stroke_gradient(json!({"angle": 90, "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})); + let (s, p) = (screen.pixel(100, 60), pdf.pixel(100, 60)); + // The line's box has no height: the gradient runs across the line's length, so the stroke + // shows its middle. + assert!(s[1] < 60 && s[0] > 80 && s[2] > 80, "screen: the stroke is painted, {s:?}"); + assert!((0..3).all(|i| s[i].abs_diff(p[i]) < 40), "screen {s:?}, pdf {p:?}"); + } } #[cfg(test)] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 64434732a..8da90186e 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -978,7 +978,7 @@ impl Exporter<'_> { /// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown. fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { - let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); + let (swatch, tint) = (fill.swatch.as_str(), fill.tint); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { let kind = g.kind; let expanded = g.expanded_stops(); @@ -1036,13 +1036,7 @@ impl Exporter<'_> { .into() } _ => { - let a = angle.unwrap_or(0.0).to_radians(); - let half = ((bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0).max(1e-3); - let d = Vec2::new(a.cos(), -a.sin()) * half; - let (p0, p1) = match v { - Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), designcraft_geom::Point::new(x1, y1)), - None => (c - d, c + d), - }; + let (p0, p1) = fill.linear_axis(bounds); LinearGradient { x1: p0.x as f32, y1: p0.y as f32, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f4085b270..07b08a812 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -889,7 +889,7 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { /// Set a swatch fill (solid or gradient) as the paint. Returns false for [None]/unknown. fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_doc::Fill, bounds: Rect) -> bool { - let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); + let (swatch, tint) = (fill.swatch.as_str(), fill.tint); if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) { let stops: Vec = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); if stops.is_empty() { @@ -901,16 +901,13 @@ fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_do let r = Vec2::new(x1 - x0, y1 - y0).hypot().max(1e-3); peniko::Gradient::new_radial((x0, y0), r as f32).with_stops(stops.as_slice()) } - (_, Some([x0, y0, x1, y1])) => peniko::Gradient::new_linear((x0, y0), (x1, y1)).with_stops(stops.as_slice()), (designcraft_color::GradientKind::Radial, None) => { let r = bounds.width().max(bounds.height()) / 2.0; peniko::Gradient::new_radial(c, r as f32).with_stops(stops.as_slice()) } _ => { - let a = angle.unwrap_or(0.0).to_radians(); - let half = (bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0; - let d = Vec2::new(a.cos(), -a.sin()) * half; - peniko::Gradient::new_linear(c - d, c + d).with_stops(stops.as_slice()) + let (a, b) = fill.linear_axis(bounds); + peniko::Gradient::new_linear(a, b).with_stops(stops.as_slice()) } }; ctx.set_paint(grad); From a2c0cb79cec994d1a18895d2eddcb4aba15a66d9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 10:39:03 +0200 Subject: [PATCH 044/128] A radial gradient on a box with no width or height is a circle across it Review fix from aiongg (6db40bdb, the radial_ellipse part), ported to this branch: a straight line's box has no ellipse, so its stroke's radial gradient showed only the last stop once #823's gradient strokes land. Co-authored-by: aiongg Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/item.rs | 8 +++++++- crates/engine/src/cmd/style.rs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index a3b724b0d..9fdb00f2b 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -44,7 +44,13 @@ impl Fill { let r = designcraft_geom::Vec2::new(x1 - x0, (y1 - y0) / k).hypot(); (designcraft_geom::Point::new(x0, y0), r, r * k) } - None => (b.center(), b.width().max(1e-9) / 2.0, b.height().max(1e-9) / 2.0), + // A box with no width or height (a straight line's) has no ellipse: a circle across + // its length. + None if b.width() <= 1e-9 || b.height() <= 1e-9 => { + let r = b.width().max(b.height()).max(1e-3) / 2.0; + (b.center(), r, r) + } + None => (b.center(), b.width() / 2.0, b.height() / 2.0), } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 8ce7a3854..614a5101e 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1880,6 +1880,22 @@ mod radial_gradient_tests { let (c, side, top) = sides(&r.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}"); } + + #[test] + fn a_box_with_no_height_or_width_has_a_circle_across_its_length() { + let f = designcraft_doc::Fill::swatch("g"); + let line = designcraft_geom::Rect::new(20.0, 60.0, 180.0, 60.0); + let (c, rx, ry) = f.radial_ellipse(line); + assert_eq!((c.x, c.y, rx, ry), (100.0, 60.0, 80.0, 80.0)); + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(10.0, 0.0, 10.0, 50.0)); + assert_eq!((rx, ry), (25.0, 25.0)); + // A point: a tiny circle, never a zero radius. + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(5.0, 5.0, 5.0, 5.0)); + assert!(rx > 0.0 && rx == ry); + // A box keeps its ellipse. + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(0.0, 0.0, 100.0, 200.0)); + assert_eq!((rx, ry), (50.0, 100.0)); + } } #[cfg(test)] From b6a62dbd995e1e4ffda4314a36ab7bd95cc0af16 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 10:56:27 +0200 Subject: [PATCH 045/128] EPUB nav: keep a heading under its parent when it is shallower than the one before it Headings h1, h3, h2 put the h2 at the top level: the loop closed every level deeper than the new entry. Close a level only while its parent is as deep as the new entry or deeper, as in aiongg's integration branch (#259, bc9b89fc), with both nesting cases as tests. --- crates/epub/src/lib.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 57e7c9d88..429560655 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -556,7 +556,8 @@ fn nav_list(entries: &[(String, String, u8)]) -> String { open.push(*level); } Some(_) => { - while open.len() > 1 && open.last().is_some_and(|t| t > level) { + // Close the levels whose parent is as deep as this entry or deeper. + while open.len() > 1 && open.len().checked_sub(2).and_then(|i| open.get(i)).is_some_and(|parent| *parent >= *level) { open.pop(); out.push_str(""); } @@ -926,6 +927,17 @@ mod tests { plain.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Opening\nrest", ParaFormat::default()).unwrap(); let nav = read(&plain, &EpubOptions::default(), "OEBPS/nav.xhtml"); assert!(nav.contains(&format!("

Contents

  1. {}
", link("s0", "Opening"))), "{nav}"); + // A heading shallower than the one before it but deeper than its parent stays under the + // parent: h1 A, h3 B, h2 C nest B and C under A. + let e = |t: &str, l: u8| (t.to_string(), t.to_string(), l); + assert_eq!( + nav_list(&[e("A", 1), e("B", 3), e("C", 2), e("D", 1)]), + format!("
  • {}
    1. {}
    2. {}
  • {}
  • ", link("A", "A"), link("B", "B"), link("C", "C"), link("D", "D")) + ); + assert_eq!( + nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)]), + format!("
  • {}
    1. {}
      1. {}
  • {}
  • ", link("A", "A"), link("B", "B"), link("C", "C"), link("D", "D")) + ); } #[test] From 1ad6dd8e397a3e279dcc2643f11fccf04c965322 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 11:02:52 +0200 Subject: [PATCH 046/128] EPUB chapters: point in-document links at the chapter file of their target After the split an href="#id" whose target ended up in another chapter file is dead; endnotes (#798) end the book, so their #en-N / #enref-N links cross files. Rewrite such links to file#id from the id-to-file map, as in aiongg's integration branch (#259, 0b353c6f). The test calls the rewrite directly, since note export comes with #798. --- crates/epub/src/lib.rs | 47 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index e7ab914f5..e9cb16cec 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -552,6 +552,28 @@ fn split_chapters(body: &str) -> Vec { out } +/// `chapter` (the file `own`) with each in-document link (`href="#id"`) to an element in another +/// chapter file pointed at that file. +fn link_across_chapters(chapter: &str, own: &str, files: &BTreeMap) -> String { + const HREF: &str = "href=\"#"; + let mut out = String::with_capacity(chapter.len()); + let mut rest = chapter; + while let Some(i) = rest.find(HREF) { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i + HREF.len()..).unwrap_or_default(); + let id = tail.split('"').next().unwrap_or_default(); + match files.get(id) { + Some(file) if file != own => { + let _ = write!(out, "href=\"{file}#"); + } + _ => out.push_str(HREF), + } + rest = tail; + } + out.push_str(rest); + out +} + /// The package file of chapter `k` (0-based): `content.xhtml`, then `content2.xhtml`, …. fn chapter_file(k: usize) -> String { if k == 0 { "content.xhtml".to_string() } else { format!("content{}.xhtml", k + 1) } @@ -844,6 +866,8 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr } } } + // Note links whose target ended up in another file (endnotes end the book) point at it. + let chapters: Vec = chapters.iter().enumerate().map(|(k, c)| link_across_chapters(c, &chapter_file(k), &files)).collect(); let mut nav_items = nav_list(&toc, &files); if nav_items.is_empty() { nav_items = format!("
  • {}
  • ", xml_text(&title)); @@ -1003,13 +1027,14 @@ mod tests { // A heading shallower than the one before it but deeper than its parent stays under the // parent: h1 A, h3 B, h2 C nest B and C under A. let e = |t: &str, l: u8| (t.to_string(), t.to_string(), l); + let l = |t: &str| link("content.xhtml", t, t); assert_eq!( nav_list(&[e("A", 1), e("B", 3), e("C", 2), e("D", 1)], &BTreeMap::new()), - format!("
  • {}
    1. {}
    2. {}
  • {}
  • ", link("content.xhtml", "A", "A"), link("content.xhtml", "B", "B"), link("content.xhtml", "C", "C"), link("content.xhtml", "D", "D")) + format!("
  • {}
    1. {}
    2. {}
  • {}
  • ", l("A"), l("B"), l("C"), l("D")) ); assert_eq!( nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)], &BTreeMap::new()), - format!("
  • {}
    1. {}
      1. {}
  • {}
  • ", link("content.xhtml", "A", "A"), link("content.xhtml", "B", "B"), link("content.xhtml", "C", "C"), link("content.xhtml", "D", "D")) + format!("
  • {}
    1. {}
      1. {}
  • {}
  • ", l("A"), l("B"), l("C"), l("D")) ); } @@ -1062,6 +1087,24 @@ mod tests { assert!(!html.contains(CHAPTER_MARK) && html.contains("Second text")); } + /// Note links stay working across chapter files: a link whose target ended up in another + /// file (endnotes end the book) names that file; one in its own file stays as it is. + #[test] + fn note_links_point_at_the_chapter_file_of_their_target() { + let files: BTreeMap = + [("enref-1", "content.xhtml"), ("fn-1", "content.xhtml"), ("fnref-1", "content.xhtml"), ("en-1", "content2.xhtml")] + .into_iter() + .map(|(id, f)| (id.to_string(), f.to_string())) + .collect(); + let first = "

    First1*

    x"; + assert_eq!( + link_across_chapters(first, "content.xhtml", &files), + "

    First1*

    x" + ); + let last = "
  • 1 End note
  • "; + assert_eq!(link_across_chapters(last, "content2.xhtml", &files), "
  • 1 End note
  • "); + } + #[test] fn split_chapters_keeps_sections_well_formed() { let m = CHAPTER_MARK; From 6e60bbccbe6ff10eb4ea4757df425fff2ffbd0ca Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 11:09:11 +0200 Subject: [PATCH 047/128] EPUB: read the cover image through a capped reader (256 MiB) coverImage was read with an uncapped std::fs::read, so a huge file or a device like /dev/zero could exhaust memory. Read it through the data merge's capped reader, which now also stops the read itself at the cap (a device reports no length), as in aiongg's integration branch (#259, 6eae3620), with /dev/zero as a test. --- crates/engine/src/cmd/datamerge/mod.rs | 24 +++++++++++++++--------- crates/engine/src/cmd/datamerge/tests.rs | 9 +++++++++ crates/engine/src/cmd/export.rs | 2 +- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index 3b3e1dd3b..4c258f1c9 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -767,17 +767,23 @@ fn read_path(path: &str) -> std::result::Result, String> { read_path_capped(path, MAX_DATA_FILE_BYTES) } -fn read_path_capped(path: &str, cap: u64) -> std::result::Result, String> { - match std::fs::metadata(path) { - Ok(m) if m.len() > cap => return Err(format!("{path}: the data file is larger than {} MiB", cap / (1024 * 1024))), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("{path}: the data file is missing")), - _ => {} +/// The file at `path`, refused when it is larger than `cap` bytes. The read itself stops past +/// `cap`, so a device or a file that grows while it is read can't exhaust memory. +pub(crate) fn read_path_capped(path: &str, cap: u64) -> std::result::Result, String> { + use std::io::Read; + let too_big = || format!("{path}: the file is larger than {} MiB", cap / (1024 * 1024)); + let missing = |e: std::io::Error| { + if e.kind() == std::io::ErrorKind::NotFound { format!("{path}: the file is missing") } else { format!("{path}: {e}") } + }; + if std::fs::metadata(path).map_err(missing)?.len() > cap { + return Err(too_big()); } - match std::fs::read(path) { - Ok(b) => Ok(b), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(format!("{path}: the data file is missing")), - Err(e) => Err(format!("{path}: {e}")), + let mut b = Vec::new(); + std::fs::File::open(path).map_err(missing)?.take(cap.saturating_add(1)).read_to_end(&mut b).map_err(missing)?; + if b.len() as u64 > cap { + return Err(too_big()); } + Ok(b) } fn parent_dir(path: &str) -> Option { diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index f4c2004e4..98849eae1 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -1086,6 +1086,15 @@ fn oversized_data_file_is_refused_before_reading() { assert_eq!(read_path_capped(&path, 4096).unwrap().len(), 2048); } +/// A device reports no length; the read still stops at the cap (an EPUB `coverImage` of +/// `/dev/zero` must not read until memory runs out). +#[cfg(unix)] +#[test] +fn endless_file_is_refused_at_the_cap() { + let msg = read_path_capped("/dev/zero", 1024).unwrap_err(); + assert!(msg.contains("larger than"), "{msg}"); +} + /// #198: with facing pages on, one record per page must not stack the copies at identical /// coordinates on one spread (leaving a later page empty). #[test] diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 0b4a91d32..c29a3a66e 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -132,7 +132,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; // A cover image file (PNG or JPEG), else the first page rendered when `cover` asks for it. let cover = match p.get("coverImage").and_then(Value::as_str).filter(|_| !fixed) { - Some(path) => Some(std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?), + Some(path) => Some(super::datamerge::read_path_capped(path, super::datamerge::MAX_IMAGE_FILE_BYTES).map_err(crate::EngineError::Other)?), None => (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(), }; if let Some(c) = &cover From 592e6026f406f1b1af44e7b602a14b42962f9962 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 11:19:31 +0200 Subject: [PATCH 048/128] EPUB fonts: skip bitmap-only fonts, and write the face's numeric weight A font whose fsType allows embedding bitmaps only (bit 9, 0x0200) was packaged with its outlines; it is now refused like a restricted one. char_css wrote font-weight: bold for any style containing "bold", so Semibold showed as Bold and Light as Regular; it now writes the numeric weight (100-900) and style from the face's style name. Same approach as aiongg's integration branch (#259, 8f54d1cc), with his test. --- crates/engine/src/cmd/export.rs | 32 +++++++++++++++++++++++++++++++- crates/epub/src/lib.rs | 15 ++++++--------- crates/fonts/src/fontdb.rs | 7 +++++++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 7975c6842..28c068bf4 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -155,7 +155,7 @@ fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec) -> let face = db.face(&family, &style); if !face.family.eq_ignore_ascii_case(&family) { warnings.push(format!("font `{family} {style}` is not available; not embedded")); - } else if face.restricted_licence() { + } else if face.restricted_licence() || face.bitmap_only() { warnings.push(format!("font `{family} {style}`: its licence doesn't allow embedding; not embedded")); } else if face.data().starts_with(b"ttcf") { warnings.push(format!("font `{family} {style}` is part of a font collection; not embedded")); @@ -753,6 +753,36 @@ mod text_tests { assert!(html.contains("\"Source Sans 3\", sans-serif;")); } + /// A font whose licence allows embedding bitmaps only (fsType bit 9) is not packaged whole, + /// and a Semibold style asks for the Semibold face's weight, not Bold's. + #[test] + fn epub_embedding_follows_bitmap_only_licences_and_weights() { + use designcraft_fonts::testing::{font_with, with_fs_type}; + const BITMAP: &str = "DocFont Epub Bitmap Only"; + for fs_type in [0x0200, 0x0208] { + designcraft_fonts::FontDb::global() + .add_font(with_fs_type(font_with(&format!("{BITMAP} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap()); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + for fs_type in [0x0200, 0x0208] { + let family = format!("{BITMAP} {fs_type:04x}"); + s.execute("style.paragraph.create", &json!({"name": family, "chars": {"fontFamily": family, "fontStyle": "Regular"}})).unwrap(); + } + s.execute("style.paragraph.create", &json!({"name": "Semi", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Semibold"}})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); + let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let css = read(&mut z, "OEBPS/style.css"); + assert!(!css.contains(&format!("@font-face {{ font-family: \"{BITMAP}")), "{css}"); + let warnings = r["warnings"].to_string(); + for fs_type in [0x0200, 0x0208] { + assert!(warnings.contains(&format!("{BITMAP} {fs_type:04x} Regular`: its licence doesn't allow embedding")), "{warnings}"); + } + let semi = css.lines().find(|l| l.contains(".semi {")).unwrap_or_default(); + assert!(semi.contains("font-weight: 600;"), "{css}"); + } + #[test] fn flattener_rasterises_spreads_with_transparency() { let mut s = Session::new(); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index b2c73b532..6d204c7e4 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -343,15 +343,9 @@ fn hex(doc: &Document, swatch: &str, tint: f32) -> Option { fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap, out: &mut String) { let _ = write!(out, "font-family: \"{}\", {}; ", css_string(&c.font_family), generic_family(&c.font_family, generics)); - let st = c.font_style.to_lowercase(); - if st.contains("bold") { - out.push_str("font-weight: bold; "); - } else if st.contains("semibold") { - out.push_str("font-weight: 600; "); - } - if st.contains("italic") { - out.push_str("font-style: italic; "); - } + // The face's own weight, so an embedded Semibold or Light face is the one chosen. + let (weight, style) = css_weight_style(&c.font_style); + let _ = write!(out, "font-weight: {weight}; font-style: {style}; "); let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size); if c.tracking != 0.0 { let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0); @@ -990,6 +984,9 @@ mod tests { assert_eq!(css_weight_style("Bold"), (700, "normal")); assert_eq!(css_weight_style("Extra Bold"), (800, "normal")); assert_eq!(css_weight_style("Regular"), (400, "normal")); + assert_eq!(css_weight_style("Light"), (300, "normal")); + assert_eq!(css_weight_style("Thin Oblique"), (100, "italic")); + assert_eq!(css_weight_style("Black"), (900, "normal")); let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index bbe08af09..ff8a9580f 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -321,6 +321,13 @@ impl FontFace { let fs_type = self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type()); fs_type & 0x0002 != 0 && fs_type & 0x000C == 0 } + + /// Does the font's licence allow embedding bitmaps only? OS/2 `fsType` bit 9: the outlines + /// may not be passed on, so the font file can't be packaged whole. + pub fn bitmap_only(&self) -> bool { + use skrifa::raw::TableProvider; + self.skrifa().and_then(|f| f.os2().ok()).is_some_and(|t| t.fs_type() & 0x0200 != 0) + } } #[cfg(not(target_arch = "wasm32"))] From 53374aab24a8f41606eda76dd12bdb893dda0aba Mon Sep 17 00:00:00 2001 From: Andres Torresi Date: Wed, 14 Oct 2026 10:30:59 +0100 Subject: [PATCH 049/128] Type tool: dead keys and input-method composition on the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing into a text frame never turned on the OS input method, so macOS dead keys didn't compose: ⌥E then I typed "ei" instead of "í" (same for ⌥N, ⌥U and Japanese/Chinese input). The canvas now asks for the input method while the Type tool has a caret and handles its events. The text being composed shows in the story as an interaction preview (new `text.compose` command), so it takes the format the next typed character gets, drawn on a yellow box. On commit it's dropped and the result typed with `text.insert` (one undo step); any other edit, tool change or drag drops it first and tells the input method to forget it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 100 ++++++++++++++++++++++++++ crates/engine/src/lib.rs | 8 +++ crates/engine/src/tooling.rs | 24 +++++++ crates/ui-egui/src/canvas.rs | 129 +++++++++++++++++++++++++++++----- 4 files changed, 243 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0b87820ff..3ff557daa 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -55,6 +55,18 @@ pub fn specs() -> Vec { ok() }), cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert), + // Neither journalled nor undoable: what is composed is a preview until it's typed. + CommandSpec { + id: "text.compose", + label: "Compose Text", + menu: &[], + shortcut: None, + params: "{text} — input-method text being composed (a dead key's accent), shown at the caret in the text's format until text.insert types the result; \"\" drops it", + enabled: has_doc, + run: compose_text, + journal: false, + undoable: false, + }, cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete), cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret), cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| { @@ -492,6 +504,30 @@ fn release(s: &mut Session, p: &Value) -> Result { }) } +/// `text.compose`: show input-method text at the caret, typed in the format the next +/// character would get, without making it part of the story. Replaces what was composed before. +fn compose_text(s: &mut Session, p: &Value) -> Result { + let text = super::text_param(p, "text"); + s.end_composition(); + if text.is_empty() { + return ok(); + } + has_text(s).map_err(|e| bad("text.compose", &e))?; + // The preview types the text; the format chosen at the caret is still for the real typing. + let typing = s.typing_format.clone(); + s.begin_interaction("Type")?; + let r = s.preview("text.insert", json!({"text": text, "raw": true})); + s.typing_format = typing; + if let Err(e) = r { + s.cancel_interaction(); + return Err(e); + } + let uid = s.doc()?.uid; + s.composition = Some((uid, text.len())); + let pos = s.doc()?.selection.text.map_or(0, |t| t.focus); + Ok(json!({"range": [pos.saturating_sub(text.len()), pos]})) +} + fn insert(s: &mut Session, p: &Value) -> Result { let text = super::text_param(p, "text"); // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). @@ -2558,3 +2594,67 @@ mod sentence_selection_tests { assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); } } + +#[cfg(test)] +mod compose_tests { + use serde_json::json; + + use crate::Session; + + /// A story "Ta" with the caret after "T" and 30 pt chosen for the next typing. + fn session() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Ta"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.set_tool("type"); + s.execute("text.select", &json!({"story": sid, "anchor": 1})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + (s, sid) + } + + fn text(s: &Session, sid: u64) -> String { + s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone() + } + + /// ⌥E shows the accent in the story, in the format it will be typed in, then I types "í": + /// one undo step, and the accent was never part of the document. + #[test] + fn composed_text_shows_in_the_typing_format_until_it_is_typed() { + let (mut s, sid) = session(); + let r = s.execute("text.compose", &json!({"text": "´"})).unwrap(); + assert_eq!(r["range"], json!([1, 3])); + assert_eq!(text(&s, sid), "T´a"); + let size_at = |s: &Session, b| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size; + assert_eq!(size_at(&s, 2), Some(30.0), "the accent is shown in the format chosen at the caret"); + assert_eq!(s.composition().map(|c| c.2), Some(1..3)); + s.execute("text.compose", &json!({"text": "¨"})).unwrap(); + assert_eq!(text(&s, sid), "T¨a", "a new composition replaces the last one"); + s.execute("text.compose", &json!({"text": ""})).unwrap(); + assert_eq!(text(&s, sid), "Ta"); + assert!(s.composition().is_none()); + s.execute("text.insert", &json!({"text": "í"})).unwrap(); + assert_eq!(text(&s, sid), "Tía"); + assert_eq!(size_at(&s, 2), Some(30.0), "the typing format survived the composition"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, sid), "Ta"); + } + + /// Anything else that edits, a tool change or a drag drops the uncommitted accent. + #[test] + fn other_edits_drop_the_composed_text() { + for act in 0..3 { + let (mut s, sid) = session(); + s.execute("text.compose", &json!({"text": "´"})).unwrap(); + match act { + 0 => { + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + } + 1 => s.set_tool("selection"), + _ => s.begin_interaction("Move").unwrap(), + } + assert_eq!(text(&s, sid), "Ta", "case {act}"); + assert!(s.composition().is_none()); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 900bd8863..537f2f416 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -278,6 +278,9 @@ pub struct Session { pub(crate) text_drag: bool, /// Character formatting chosen at a bare caret, for the text typed there next. pub(crate) typing_format: Option, + /// Text an input method is composing (a dead key's accent), shown in the story as an + /// interaction preview until it commits: (document uid, byte length before the caret). + pub(crate) composition: Option<(u64, usize)>, /// The open Object Library (File › New / Open Library). pub library: Option, /// The open book (File › Open Book). @@ -310,6 +313,7 @@ impl Session { transforms: Default::default(), text_drag: false, typing_format: None, + composition: None, library: None, conveyor: Vec::new(), book: None, @@ -405,6 +409,10 @@ impl Session { fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + // Text still being composed isn't part of the story: any other edit drops it first. + if id != "text.compose" && (spec.undoable || spec.journal) { + self.end_composition(); + } if let Err(e) = (spec.enabled)(self) { // A command that takes `ids` acts on the objects the call names, so it needs no // selection: with a text caret or nothing selected, `{"ids": [5]}` still runs. diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1bf8db930..98868b7d6 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -2,6 +2,7 @@ use std::sync::Arc; +use designcraft_doc::{CellAddr, StoryId}; use designcraft_geom::Unit; pub use designcraft_tools::SnapView; use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey}; @@ -58,6 +59,7 @@ impl Session { if self.tool.id() == id { return; } + self.end_composition(); self.tool = designcraft_tools::create(id); // Leaving the Type tool keeps the text frame selected. if id != "type" @@ -143,7 +145,29 @@ impl Session { Ok(()) } + /// The text an input method is composing at the caret, as story bytes: it is in the + /// document as a preview until `text.compose` commits or drops it. + pub fn composition(&self) -> Option<(StoryId, Option, std::ops::Range)> { + let (uid, len) = self.composition?; + let st = self.active().filter(|st| st.uid == uid)?; + let t = st.selection.text?; + Some((t.story, t.cell, t.focus.checked_sub(len)?..t.focus)) + } + + /// Drop the text being composed: the document goes back to how it was before it. + pub fn end_composition(&mut self) { + let Some((uid, _)) = self.composition.take() else { return }; + if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid) + && let Some(it) = st.interaction.take() + { + st.doc = it.doc; + st.selection = it.selection; + st.revision += 1; + } + } + pub fn begin_interaction(&mut self, label: &str) -> Result<()> { + self.end_composition(); let st = self.doc_mut()?; st.interaction = Some(Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None }); Ok(()) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5d8218b77..7a50c15bd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -991,7 +991,11 @@ fn draw_text_selection( cells: Option, ) { let cs = app.session.cache.get(doc, ts.story, None); - let range = ts.range(); + // Text an input method is composing at the caret is marked like it is in every Mac text + // field: a yellow box behind the glyphs. + let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2); + let range = composing.clone().unwrap_or_else(|| ts.range()); + let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.7) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) }; // Selected table cells: tint every selected cell. if let Some(sel) = cells.filter(|c| c.story == ts.story) { let selected = doc.story(sel.story).and_then(|s| s.tables.get(&sel.table)).map(|t| t.selected_owners(sel.range)).unwrap_or_default(); @@ -1044,7 +1048,7 @@ fn draw_text_selection( let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64); let poly = [Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p))); - painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE)); } } } @@ -1080,7 +1084,7 @@ fn draw_text_selection( } } if !runs.is_empty() { - draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs); + draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs, composing.is_some()); } if ts.is_caret() && let Some((fi, x, bl, asc, desc)) = compose::caret(&cs, ts.focus) @@ -1088,22 +1092,38 @@ fn draw_text_selection( && let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) { let m = a * doc.text_xf(it); + // Type on a path: the caret stands across the path where it is along it. + let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p)); + let p0 = xf.to_screen(m * on(Point::new(x, bl - asc))); + let p1 = xf.to_screen(m * on(Point::new(x, bl + desc))); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { - // Type on a path: the caret stands across the path where it is along it. - let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p)); - let p0 = xf.to_screen(m * on(Point::new(x, bl - asc))); - let p1 = xf.to_screen(m * on(Point::new(x, bl + desc))); painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK)); } + remember_ime_caret(painter, p0, p1); painter.ctx().request_repaint_after(std::time::Duration::from_millis(330)); } } +/// Where the text caret stood on screen last frame: the OS puts its input-method window there. +fn ime_caret_id() -> egui::Id { + egui::Id::new("canvas_ime_caret") +} + +/// The input method is composing text (sent to `text.compose`) that it hasn't committed. +fn ime_composing_id() -> egui::Id { + egui::Id::new("canvas_ime_composing") +} + +/// Remembers the caret at `p0`–`p1` for the input method's window. +fn remember_ime_caret(painter: &egui::Painter, p0: egui::Pos2, p1: egui::Pos2) { + painter.ctx().data_mut(|d| d.insert_temp(ime_caret_id(), egui::Rect::from_two_pos(p0, p1))); +} + /// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin). #[allow(clippy::too_many_arguments)] fn draw_cell_selection( - _app: &DesignApp, + app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, @@ -1121,7 +1141,9 @@ fn draw_cell_selection( let Some(ft) = cs.frames.get(fi) else { return }; let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return }; let m = a * doc.text_xf(it) * designcraft_geom::Affine::translate(origin.to_vec2()); - let range = ts.range(); + let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2); + let range = composing.clone().unwrap_or_else(|| ts.range()); + let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.6) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) }; if !range.is_empty() && let Some(cft) = text.frames.first() { @@ -1134,7 +1156,7 @@ fn draw_cell_selection( let past = e == l.range.end && range.end > l.range.end; for q in compose::highlight_quads(l, s, e, past) { let poly = q.map(|p| xf.to_screen(m * p)); - painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE)); } } } @@ -1145,20 +1167,25 @@ fn draw_cell_selection( } { let m = a * doc.text_xf(it); + let (p0, p1) = (xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { - painter.line_segment( - [xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))], - Stroke::new(1.0, Color32::BLACK), - ); + painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK)); } + remember_ime_caret(painter, p0, p1); painter.ctx().request_repaint_after(std::time::Duration::from_millis(330)); } } +/// The box behind text an input method is composing. +const COMPOSING_FILL: Color32 = Color32::from_rgb(255, 247, 153); + /// InDesign draws selected text inverted: a black highlight with the selected glyphs in the /// inverse of their colour (white for black text). The highlight and glyph outlines are /// rasterised with `vello_cpu` into a small screen-space texture over the selection. +/// `composing`: the text is an input method's, still being composed: a yellow box with the +/// glyphs in their own colour. +#[allow(clippy::too_many_arguments)] fn draw_inverse_highlight( app: &DesignApp, painter: &egui::Painter, @@ -1167,6 +1194,7 @@ fn draw_inverse_highlight( cs: &compose::ComposedStory, story: designcraft_doc::StoryId, runs: &[(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)], + composing: bool, ) { use designcraft_render::vello_cpu::{self, kurbo, peniko}; let ctx = painter.ctx(); @@ -1193,7 +1221,7 @@ fn draw_inverse_highlight( let Some(st) = app.session.active() else { return }; let key = egui::Id::new(("inverse_sel", st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, story.0)) .with((runs.iter().map(|r| r.2.len() + r.3.len() * 7919).sum::(), w, h)) - .with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)))); + .with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)), composing)); let cache_id = egui::Id::new("inverse_sel_tex"); let cached: Option<(egui::Id, egui::TextureHandle)> = ctx.data(|d| d.get_temp(cache_id)); let tex = match cached { @@ -1201,7 +1229,8 @@ fn draw_inverse_highlight( _ => { let view = xf.affine(ppp as f64, bbox.min); let mut rc = vello_cpu::RenderContext::new_with(w as u16, h as u16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); - rc.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); + let [br, bg, bb, _] = if composing { COMPOSING_FILL.to_array() } else { [0, 0, 0, 255] }; + rc.set_paint(peniko::Color::from_rgba8(br, bg, bb, 255)); for (_, m, quads, _) in runs { rc.set_transform(view * *m); for q in quads { @@ -1222,8 +1251,12 @@ fn draw_inverse_highlight( let Some(g) = l.glyphs.get(gi) else { continue }; let Some(style) = cs.styles.get(g.style as usize) else { continue }; let Some(c) = doc.resolve_color(&style.fill, style.fill_tint) else { continue }; - let [r, gg, b, _] = c.to_rgba8(1.0); - rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255)); + let [r, gg, b, a] = c.to_rgba8(1.0); + if composing { + rc.set_paint(peniko::Color::from_rgba8(r, gg, b, a)); + } else { + rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255)); + } let outline = db.outline(&g.face, g.gid); let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; @@ -1533,12 +1566,41 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) { return; } + // The engine drops composed text when something else edits or the caret moves; the input + // method must forget it too, or the next key would finish an accent that is gone. + let composing = ui.data(|d| d.get_temp::(ime_composing_id())).unwrap_or(false); + let interrupt = composing && app.session.composition().is_none(); + if interrupt || !wants_text { + ui.data_mut(|d| d.remove::(ime_composing_id())); + } + if wants_text { + // Turn the OS input method on: macOS composes dead keys (⌥E then I types í) and + // Japanese/Chinese input only for a window that asks for it. Without it ⌥E typed "e". + let cursor_rect = ui.data(|d| d.get_temp(ime_caret_id())).unwrap_or(egui::Rect::from_min_size(rect.min, egui::Vec2::ZERO)); + ui.ctx().output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect, cursor_rect, should_interrupt_composition: interrupt }); + }); + } let evs = ui.input(|i| i.events.clone()); for e in evs { match e { egui::Event::Text(t) if wants_text => { let _ = app.run("text.insert", json!({"text": t})); } + // What is composed shows in the story, in the text's own format, until it commits. + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) if wants_text => { + let on = !text.is_empty(); + if app.run("text.compose", json!({"text": text})).is_ok() { + ui.data_mut(|d| d.insert_temp(ime_composing_id(), on)); + } + } + egui::Event::Ime(egui::ImeEvent::Commit(t)) if wants_text => { + ui.data_mut(|d| d.remove::(ime_composing_id())); + let _ = app.run("text.compose", json!({"text": ""})); + if !t.is_empty() { + let _ = app.run("text.insert", json!({"text": t})); + } + } egui::Event::Paste(t) if wants_text => { // Formatted when the system clipboard still holds what was copied here; // Shift (⇧⌘V) pastes without formatting. @@ -2088,6 +2150,37 @@ mod tests { assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h)); } + /// ⌥E then I types "í" on a Mac: the input method composes the dead key's accent, which + /// it does only for a window that turned it on. The canvas never did, so ⌥E typed "e". + #[test] + fn dead_keys_compose_through_the_input_method() { + let (mut h, id) = typing("T"); + h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [390.0, 80.0]})).unwrap(); + h.run_steps(2); + assert!(h.output().platform_output.ime.is_some(), "a text caret turns the input method on"); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "´".into(), active_range_chars: None })); + h.run_steps(2); + let text = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.stories.values().map(|s| s.text.clone()).collect::(); + assert_eq!(text(&h), "T´", "the accent being composed shows in the story, in its format"); + assert!(h.state().session.composition().is_some(), "and is marked as composed, not typed"); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: String::new(), active_range_chars: None })); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("í".into()))); + h.run_steps(2); + assert_eq!(text(&h), "Tí"); + assert!(h.state().session.composition().is_none()); + // Moving the caret mid-composition drops the accent and tells the input method so. + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "˜".into(), active_range_chars: None })); + h.run_steps(2); + h.state_mut().run("text.move", json!({"dir": "left"})).unwrap(); + h.step(); + assert_eq!(text(&h), "Tí"); + assert!(h.output().platform_output.ime.as_ref().is_some_and(|i| i.should_interrupt_composition)); + // Another tool takes no text: the input method is off. + h.state_mut().run("tool.select", json!({"tool": "selection"})).unwrap(); + h.run_steps(2); + assert!(h.output().platform_output.ime.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())); From 188c4f7007f0bb05dfc198d7dae478e4149f796e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 12:03:58 +0200 Subject: [PATCH 050/128] Facing pages take their side from their section's page numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Nielsen's "Numbering Page Sections" (7:03): starting a section at page 1 on a left page (the spread 8–9 selected) turns it into a lone right-hand page 1 in InDesign, and the pages after it shuffle. DesignCraft kept the page on the left, so odd page numbers sat on left pages. - Document::repaginate gives each facing page the side of its number in its section (odd: right, or left bound right to left). Document Setup's Start Page # is one case of it. - layout.section repaginates when a section starts, changes or is removed; objects go with their pages, in one undo step. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/build.rs | 20 ++++++-- crates/engine/src/cmd/layout.rs | 90 +++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..f1195daed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · In a facing document, pages take their side from their section's page numbers, as InDesign shuffles them: a section that starts at 1 on a left page moves it to the right and the pages after it follow, with their objects. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c274f2130..6c6ab0b8c 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -267,15 +267,26 @@ impl Document { /// Redistribute document pages into spreads (facing: page 1 alone on the right, then pairs). /// Items move with the page their centre is on. Spreads with `allow_shuffle == false` are kept. + /// Facing pages take their side from their page number, as InDesign shuffles them: an odd + /// number is a right page (a left page bound right to left), so a section that starts at 1 on + /// a left page moves it to the right and the pages after it follow. pub fn repaginate(&mut self) { - // Collect pages with their items (items in page-relative coordinates). + let odd: Vec = (0..self.page_count()).map(|i| self.page_number(i) % 2 == 1).collect(); + // Collect pages with their items (items in page-relative coordinates) and their numbers' parity. let mut pages: Vec<(Page, Vec>)> = Vec::new(); + let mut parity: Vec = Vec::new(); let mut kept: Vec<(usize, Arc)> = Vec::new(); + let mut abs = 0; for sp in self.spreads.drain(..) { if !sp.allow_shuffle && sp.pages.len() > 1 { + abs += sp.pages.len(); kept.push((pages.len(), sp)); continue; } + for _ in &sp.pages { + parity.push(odd.get(abs).copied().unwrap_or(abs % 2 == 0)); + abs += 1; + } let mut sp = Arc::unwrap_or_clone(sp); let mut per: Vec>> = vec![Vec::new(); sp.pages.len()]; for it in std::mem::take(&mut sp.items) { @@ -326,13 +337,12 @@ impl Document { } spreads.push(sp); }; - // An even start page number (Document Setup › Start Page #) begins with a left page. - let shift = usize::from(self.sections.iter().find(|x| x.start == 0).and_then(|x| x.start_number).is_some_and(|n| n % 2 == 0)); for (i, (mut p, items)) in pages.into_iter().enumerate() { if facing { - // Page i (0-based) is a right page when its number is odd (1, 3, 5 …). + // A page is a right page when its number is odd (1, 3, 5 …), counting each + // section from its start number (Document Setup › Start Page # included). // Bound right to left, odd pages are left pages and spreads start on the right. - let odd = (i + shift) % 2 == 0; + let odd = parity.get(i).copied().unwrap_or(i % 2 == 0); p.side = if odd != rtl { PageSide::Right } else { PageSide::Left }; let starts = if rtl { PageSide::Right } else { PageSide::Left }; if p.side == starts && !cur.is_empty() { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..6cf6432e9 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -398,6 +398,8 @@ pub fn specs() -> Vec { s.edit(|d, _| { d.sections.retain(|x| x.start != start || start == 0); if p.get("remove").and_then(Value::as_bool).unwrap_or(false) && start != 0 { + // The pages after it follow the previous section's numbers again. + d.repaginate(); return ok(); } let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { @@ -428,6 +430,8 @@ pub fn specs() -> Vec { d.sections.retain(|x| x.start != start); d.sections.push(sec); d.sections.sort_by_key(|x| x.start); + // Facing pages take their sides from their numbers: odd on the right. + d.repaginate(); Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>()})) }) } @@ -921,3 +925,89 @@ mod adjust_tests { assert_eq!(b, b2); } } + +#[cfg(test)] +mod section_side_tests { + use serde_json::json; + + use crate::Session; + + /// Each spread's pages as (name, side), side `L`, `R` or `S`. + fn spreads(s: &Session) -> Vec> { + let d = &s.doc().unwrap().doc; + let mut abs = 0; + d.spreads + .iter() + .map(|sp| { + sp.pages + .iter() + .map(|p| { + let side = match p.side { + designcraft_doc::PageSide::Left => 'L', + designcraft_doc::PageSide::Right => 'R', + designcraft_doc::PageSide::Single => 'S', + }; + abs += 1; + (d.page_name(abs - 1), side) + }) + .collect() + }) + .collect() + } + + fn pages(v: &[&[(&str, char)]]) -> Vec> { + v.iter().map(|sp| sp.iter().map(|(n, c)| (n.to_string(), *c)).collect()).collect() + } + + #[test] + fn a_section_starting_at_one_on_a_left_page_moves_it_to_the_right() { + // Chris Nielsen's "Numbering Page Sections" (7:03): with the spread 8–9 selected, the + // section starts on page 8, which becomes a lone right-hand page 1; the pages after it + // shuffle and their objects go with them. Undo puts them back. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": true, "pages": 10})).unwrap(); + let id = s.execute("frame.create", &json!({"spread": 4, "rect": [700, 100, 800, 200], "content": "text", "text": "TOPIC"})).unwrap()["id"] + .as_u64() + .unwrap(); + let page_of = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.page_of_item(designcraft_doc::ItemId(id)).unwrap() + }; + assert_eq!(page_of(&s), 8, "page 9, the right page of the spread 8–9"); + let before = spreads(&s); + s.execute("layout.section", &json!({"page": 8, "startNumber": 1})).unwrap(); + assert_eq!( + spreads(&s), + pages(&[ + &[("1", 'R')], + &[("2", 'L'), ("3", 'R')], + &[("4", 'L'), ("5", 'R')], + &[("6", 'L'), ("7", 'R')], + &[("1", 'R')], + &[("2", 'L'), ("3", 'R')], + ]) + ); + assert_eq!(page_of(&s), 8, "the object stays on its page, now the left page of a spread"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spreads(&s), before); + // On a right page (the video's page 9) nothing moves; an even start on a right page moves + // it to the left. + s.execute("layout.section", &json!({"page": 9, "startNumber": 1})).unwrap(); + assert_eq!(spreads(&s)[4], pages(&[&[("8", 'L'), ("1", 'R')]])[0]); + s.execute("layout.section", &json!({"page": 9, "startNumber": 2})).unwrap(); + assert_eq!(spreads(&s)[4..], pages(&[&[("8", 'L')], &[("2", 'L'), ("3", 'R')]])); + // Removing the section numbers the pages on from 9 again. + s.execute("layout.section", &json!({"page": 9, "remove": true})).unwrap(); + assert_eq!(spreads(&s), before); + } + + #[test] + fn single_sided_documents_keep_their_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": false, "pages": 4})).unwrap(); + s.execute("layout.section", &json!({"page": 2, "startNumber": 1})).unwrap(); + let sp = spreads(&s); + assert_eq!(sp.len(), 4); + assert!(sp.iter().all(|p| p.len() == 1 && p[0].1 == 'S'), "{sp:?}"); + } +} From f4aa93f17d0b92a93f387b99d4f64ec4b35f3d21 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 12:38:39 +0200 Subject: [PATCH 051/128] =?UTF-8?q?Type=20menu=20in=20InDesign's=20order;?= =?UTF-8?q?=20Character=20=E2=8C=98T,=20Paragraph=20=E2=8C=A5=E2=8C=98T=20?= =?UTF-8?q?and=20Current=20Page=20Number=20=E2=8C=A5=E2=87=A7=E2=8C=98N?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Nielsen's "Numbering Page Sections" (2:09) reaches Insert Special Character › Markers › Current Page Number through InDesign's Type menu: Font, Size, Character, Paragraph, Tabs, Glyphs, Story, the style panels, Create Outlines, Find Font, Change Case, Type on a Path, Notes, Track Changes, the footnote and endnote items, Hyperlinks & Cross-References, Text Variables, Bulleted & Numbered Lists, the insert submenus, Fill with Placeholder Text and Show Hidden Characters. DesignCraft's began with Find/ Replace Font and Create Outlines and held alignment, Bold/Italic and point size steps InDesign keeps out of its menus. - Size ▸ (6–72 pt), Character, Paragraph, Glyphs, Character Styles, Paragraph Styles (InDesign's shortcuts: ⌘T, ⌥⌘T, ⌥⇧F11, ⇧⌘F11, ⌘F11), then InDesign's order. Notes ▸ holds New Note and the Notes panel. - Current Page Number carries ⌥⇧⌘N (app.insertCurrentPageNumber). - Alignment, Bold/Italic/Underline/All Caps, point size steps and Paragraph Rules leave the menu; their shortcuts and panel buttons stay. - DesignCraft's math and CJK items follow after Show Hidden Characters. - Labels "Bulleted & Numbered Lists" and "Show Hidden Characters", with translations. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 11 ++ crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/menus.rs | 217 ++++++++++++++++++++++++------- 8 files changed, 192 insertions(+), 49 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..2e164537d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Type menu follows InDesign's order (Size, Character ⌘T, Paragraph ⌥⌘T, Glyphs, the style panels, Notes, footnotes and endnotes, then the insert submenus and Show Hidden Characters); Current Page Number has ⌥⇧⌘N. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6d6503a00..0982894cf 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,17 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ( + "Bulleted & Numbered Lists", + [ + "Aufzählungszeichen und nummerierte Listen", + "Listes à puces et numérotées", + "Listas con viñetas y numeradas", + "箇条書き", + "项目符号列表和编号列表", + ], + ), + ("Current Page Number", ["Aktuelle Seitenzahl", "Numéro de page actuelle", "Número de página actual", "現在のページ番号", "当前页码"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 769511968..2707bd27b 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), + ("Current Page Number", "رقم الصفحة الحالية"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 747511ae8..00b082e01 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), + ("Current Page Number", "Numero di pagina corrente"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 263d4226a..133ae2927 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Bulleted & Numbered Lists", "箇条書き"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 3f955f68f..0d4de685e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), + ("Show Hidden Characters", "Mostrar caracteres ocultos"), + ("Current Page Number", "Número da página atual"), + ("New Note", "Nova nota"), + ("Insert Endnote", "Inserir nota de fim"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index efccfc166..36e75adc7 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), + ("Current Page Number", "Номер поточної сторінки"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dc8b88bf2..7d3163a3f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -75,6 +75,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.deletePage", "Delete Page", None, "{} — deletes the page in view"), ("app.duplicateSpread", "Duplicate Spread", None, "{} — duplicates the spread in view (pages and items)"), ("app.tablePanel", "Table Panel", Some("Shift+F9"), "{}"), + ("app.characterPanel", "Character", Some("Cmd+T"), "{} — the Character panel"), + ("app.paragraphPanel", "Paragraph", Some("Cmd+Alt+T"), "{} — the Paragraph panel"), + ("app.glyphsPanel", "Glyphs", Some("Alt+Shift+F11"), "{} — the Glyphs panel"), + ("app.characterStylesPanel", "Character Styles", Some("Cmd+Shift+F11"), "{} — the Character Styles panel"), + ("app.paragraphStylesPanel", "Paragraph Styles", Some("Cmd+F11"), "{} — the Paragraph Styles panel"), + ("app.insertCurrentPageNumber", "Current Page Number", Some("Cmd+Alt+Shift+N"), "{} — the Current Page Number marker at the insertion point"), ("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"), ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), @@ -120,7 +126,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ), ("view.baselineGrid", "Show/Hide Baseline Grid", Some("Cmd+Alt+'"), "{}"), ("view.textThreads", "Show/Hide Text Threads", Some("Cmd+Alt+Y"), "{}"), - ("view.hiddenCharacters", "Show/Hide Hidden Characters", Some("Cmd+Alt+I"), "{}"), + ("view.hiddenCharacters", "Show Hidden Characters", Some("Cmd+Alt+I"), "{}"), ("view.taggedFrames", "Show/Hide Tagged Frames", None, "{} — XML-tagged frames outlined in their tag colour"), ("window.hidePanels", "Show/Hide Panels", Some("Tab"), "{} — every panel, the Tools panel included"), ("window.hidePanelsExceptTools", "Show/Hide Panels Except Tools", Some("Shift+Tab"), "{}"), @@ -330,18 +336,76 @@ pub const MENUS: &[(&str, &[&str])] = &[ ( "Type", &[ - "ui:app.findFontDialog", + ">Size", + "cmd:type.char|6 pt|{\"attrs\": {\"size\": 6}}", + "cmd:type.char|7 pt|{\"attrs\": {\"size\": 7}}", + "cmd:type.char|8 pt|{\"attrs\": {\"size\": 8}}", + "cmd:type.char|9 pt|{\"attrs\": {\"size\": 9}}", + "cmd:type.char|10 pt|{\"attrs\": {\"size\": 10}}", + "cmd:type.char|11 pt|{\"attrs\": {\"size\": 11}}", + "cmd:type.char|12 pt|{\"attrs\": {\"size\": 12}}", + "cmd:type.char|14 pt|{\"attrs\": {\"size\": 14}}", + "cmd:type.char|18 pt|{\"attrs\": {\"size\": 18}}", + "cmd:type.char|24 pt|{\"attrs\": {\"size\": 24}}", + "cmd:type.char|30 pt|{\"attrs\": {\"size\": 30}}", + "cmd:type.char|36 pt|{\"attrs\": {\"size\": 36}}", + "cmd:type.char|48 pt|{\"attrs\": {\"size\": 48}}", + "cmd:type.char|60 pt|{\"attrs\": {\"size\": 60}}", + "cmd:type.char|72 pt|{\"attrs\": {\"size\": 72}}", + "<", + "-", + "ui:app.characterPanel", + "ui:app.paragraphPanel", + "ui:app.glyphsPanel", + "-", + "ui:app.characterStylesPanel", + "ui:app.paragraphStylesPanel", "-", "cmd:type.createOutlines", - ">Bulleted and Numbered Lists", - "cmd:list.define", + "ui:app.findFontDialog", + ">Change Case", + "cmd:type.changeCase|UPPERCASE|{\"case\": \"upper\"}", + "cmd:type.changeCase|lowercase|{\"case\": \"lower\"}", + "cmd:type.changeCase|Title Case|{\"case\": \"title\"}", + "cmd:type.changeCase|Sentence case|{\"case\": \"sentence\"}", "<", + "-", ">Type on a Path", "cmd:type.pathOptions", "cmd:type.pathOptions|Delete Type from Path|{\"delete\": true}", "<", - "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", - "cmd:type.fillWithPlaceholder", + "-", + ">Notes", + "cmd:note.new", + "ui:window.panel|Notes|{\"panel\": \"notes\"}", + "<", + ">Track Changes", + "cmd:changes.track", + "cmd:changes.acceptAll", + "cmd:changes.rejectAll", + "<", + "-", + "cmd:footnote.insert", + "ui:app.footnoteOptionsDialog", + "cmd:endnote.insert", + "cmd:footnote.goToReference", + "-", + ">Hyperlinks & Cross-References", + "cmd:hyperlink.create", + "cmd:anchor.create", + "-", + "ui:app.insertXrefDialog", + "cmd:xref.defineFormat", + "<", + ">Text Variables", + "cmd:variables.define", + "cmd:variables.insert", + "<", + "-", + ">Bulleted & Numbered Lists", + "cmd:list.define", + "<", + "-", ">Insert Special Character", ">Symbols", "cmd:text.insert|Bullet Character|{\"text\": \"\u{2022}\", \"raw\": true}", @@ -353,7 +417,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:text.insert|Trademark Symbol|{\"text\": \"\u{2122}\", \"raw\": true}", "<", ">Markers", - "cmd:text.insert|Current Page Number|{\"text\": \"\u{e000}\", \"raw\": true}", + "ui:app.insertCurrentPageNumber", "cmd:text.insert|Next Page Number|{\"text\": \"\u{e007}\", \"raw\": true}", "cmd:text.insert|Previous Page Number|{\"text\": \"\u{e008}\", \"raw\": true}", "cmd:text.insert|Section Marker|{\"text\": \"\u{e001}\", \"raw\": true}", @@ -400,44 +464,10 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:text.insert|Paragraph Return|{\"text\": \"\\n\", \"raw\": true}", "cmd:text.insert|Forced Line Break|{\"text\": \"\u{2028}\", \"raw\": true}", "<", - "-", - ">Change Case", - "cmd:type.changeCase|UPPERCASE|{\"case\": \"upper\"}", - "cmd:type.changeCase|lowercase|{\"case\": \"lower\"}", - "cmd:type.changeCase|Title Case|{\"case\": \"title\"}", - "cmd:type.changeCase|Sentence case|{\"case\": \"sentence\"}", - "<", - "-", - "cmd:type.alignLeft", - "cmd:type.alignCenter", - "cmd:type.alignRight", - "cmd:type.justify", - "ui:app.paragraphRulesDialog", - "-", - "cmd:type.bold", - "cmd:type.italic", - "cmd:type.underline", - "cmd:type.allCaps", - "-", - "cmd:type.sizeUp", - "cmd:type.sizeDown", + "cmd:type.fillWithPlaceholder", "-", "ui:view.hiddenCharacters", "-", - ">Hyperlinks & Cross-References", - "cmd:hyperlink.create", - "cmd:anchor.create", - "-", - "ui:app.insertXrefDialog", - "cmd:xref.defineFormat", - "<", - ">Text Variables", - "cmd:variables.define", - "cmd:variables.insert", - "<", - "-", - "cmd:footnote.insert", - "cmd:endnote.insert", "cmd:math.insert", ">Story Direction", "cmd:type.storyDirection|Horizontal|{\"vertical\": false}", @@ -447,13 +477,6 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.rubyDialog", "cmd:type.kenten", "cmd:type.warichu", - ">Track Changes", - "cmd:changes.track", - "cmd:changes.acceptAll", - "cmd:changes.rejectAll", - "<", - "ui:app.footnoteOptionsDialog", - "cmd:footnote.goToReference", ], ), ( @@ -1264,6 +1287,17 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let panel = match id { + "app.characterPanel" => "character", + "app.paragraphPanel" => "paragraph", + "app.glyphsPanel" => "glyphs", + "app.characterStylesPanel" => "characterStyles", + _ => "paragraphStyles", + }; + run_ui(app, "window.panel", &json!({"panel": panel})).unwrap_or(Ok(Value::Null)) + } + "app.insertCurrentPageNumber" => app.run("text.insert", json!({"text": "\u{e000}", "raw": true})), "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties").to_string(); if crate::dock::DOCK_TABS.iter().any(|(id, _, _)| *id == panel) { @@ -3511,6 +3545,91 @@ mod tests { assert!(all.iter().any(|(l, id, p)| l == "Fill Frame Proportionally" && id == "object.fit" && p["mode"] == "fillProportionally")); } + #[test] + fn the_type_menu_follows_indesigns_order() { + // InDesign's Type menu, as Chris Nielsen opens it in "Numbering Page Sections" (2:09). + let ty = menu_tree().into_iter().find(|(m, _)| *m == "Type").unwrap().1; + let labels: Vec = ty + .iter() + .map(|i| match i { + Item::Sep => "-".to_string(), + Item::Sub(n, _) => format!("{n} >"), + Item::Cmd { label, .. } => label.clone(), + }) + .collect(); + let expected = [ + "Size >", + "-", + "Character", + "Paragraph", + "Glyphs", + "-", + "Character Styles", + "Paragraph Styles", + "-", + "Create Outlines", + "Find/Replace Font…", + "Change Case >", + "-", + "Type on a Path >", + "-", + "Notes >", + "Track Changes >", + "-", + "Insert Footnote", + "Document Footnote Options…", + "Insert Endnote", + ]; + assert_eq!(&labels[..expected.len()], expected, "{labels:?}"); + let pos = |l: &str| labels.iter().position(|x| x == l).unwrap_or_else(|| panic!("{l} in {labels:?}")); + assert!(pos("Hyperlinks & Cross-References >") < pos("Text Variables >")); + assert!(pos("Text Variables >") < pos("Bulleted & Numbered Lists >")); + assert!(pos("Bulleted & Numbered Lists >") < pos("Insert Special Character >")); + assert!(pos("Insert Special Character >") < pos("Insert White Space >")); + assert!(pos("Insert Break Character >") < pos("Fill with Placeholder Text")); + assert!(pos("Fill with Placeholder Text") < pos("Show Hidden Characters")); + // Alignment, styles and point sizes keep their shortcuts, out of the menu as in InDesign. + for gone in ["Align Left", "Bold", "Increase Point Size", "Paragraph Rules…"] { + assert!(!labels.iter().any(|l| l == gone), "{gone}"); + } + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let all = effective_shortcuts(&app); + for (id, sc) in [ + ("type.alignLeft", "Cmd+Shift+L"), + ("app.characterPanel", "Cmd+T"), + ("app.paragraphPanel", "Cmd+Alt+T"), + ("app.insertCurrentPageNumber", "Cmd+Alt+Shift+N"), + ] { + assert_eq!(all.iter().find(|(i, _)| i == id).map(|(_, s)| s.as_str()), Some(sc), "{id}"); + } + // Markers › Current Page Number carries ⌥⇧⌘N. + let markers = ty + .iter() + .find_map(|i| match i { + Item::Sub(n, c) if n == "Insert Special Character" => { + c.iter().find_map(|i| if let Item::Sub(n, c) = i { (n == "Markers").then(|| c.clone()) } else { None }) + } + _ => None, + }) + .unwrap(); + assert!(matches!(&markers[0], Item::Cmd { label, shortcut: Some("Cmd+Alt+Shift+N"), .. } if label == "Current Page Number")); + } + + #[test] + fn type_panel_items_open_their_panels_and_the_marker_shortcut_inserts_a_page_number() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + activate(&mut app, "app.characterPanel", &Value::Null); + assert_eq!(app.ui.open_panel.as_deref(), Some("character")); + activate(&mut app, "app.paragraphStylesPanel", &Value::Null); + assert_eq!(app.ui.open_panel.as_deref(), Some("paragraphStyles")); + let story = app.run("frame.create", json!({"rect": [36, 36, 200, 100], "content": "text", "text": "Page "})).unwrap()["story"].clone(); + app.run("text.select", json!({"story": story, "anchor": 5, "focus": 5})).unwrap(); + activate(&mut app, "app.insertCurrentPageNumber", &Value::Null); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.story(designcraft_doc::StoryId(story.as_u64().unwrap())).unwrap().text, "Page \u{e000}"); + } + #[test] fn custom_workspaces_save_apply_and_delete() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 69240ab8c2db57d57e68c0c49a173c47dd2966d8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 12:42:45 +0200 Subject: [PATCH 052/128] ui_shot: render every script line, so long scripts don't time out on the GPU Replaying Chris Nielsen's "Numbering Page Sections" on a 50-page facing document, a script that set seven sections without screenshots in between ended in "render failed: PollError: The requested Wait timed out before the submission was completed". Harness::step doesn't render, so the texture changes of every frame (each edit redraws the Pages panel's 50 thumbnails) waited for the screenshot's render, which then uploaded them all at once. ui_shot now renders after each script line (the image is dropped), so texture changes are applied as they come. The same script renders. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/examples/ui_shot.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index c0bf38943..0373a578d 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -78,6 +78,12 @@ fn main() { } step_n(&mut harness, 2); } + // Frames that aren't rendered keep their texture changes for the next render: a script + // that edits a long document (every edit redraws the Pages panel's thumbnails) without + // screenshots piled them up until the shot's render timed out on the GPU. Render each line. + if l.get("shot").is_none() { + let _ = harness.render(); + } } } From edb268af12239cc474759f8ab1029f39b59c7898 Mon Sep 17 00:00:00 2001 From: Govinda Roy Date: Wed, 14 Oct 2026 18:22:44 +0600 Subject: [PATCH 053/128] Text: kerning takes a typed value, and the Character panel has it The kerning control in Properties was a menu of Metrics, Optical and 0: manual kerning, which the engine already supports in 1/1000 em, couldn't be entered, and the Character panel had no kerning at all (#896). - One kerning control: its label shows Metrics, Optical, 0 or the manual value; its menu keeps the three choices and adds a number field with a spinner for a manual value (-1000 to 10000). - Properties and the Character panel (next to Tracking) both use it. Fixes #896. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/properties.rs | 112 ++++++++++++++++++++---- 1 file changed, 93 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 4e207c44f..9e6030bd5 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1087,24 +1087,7 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { } // Kerning / tracking. icons::paint(ui.painter(), sub(b, 0.0, 27.0, 19.0, 19.0), "kerning", t.icon); - let kern = c["kerning"]["kind"].as_str().unwrap_or("metrics").to_string(); - let kern_label = match kern.as_str() { - "optical" => "Optical".to_string(), - "none" => "0".to_string(), - "manual" => format!("{}", c["kerning"]["value"].as_f64().unwrap_or(0.0)), - _ => "Metrics".to_string(), - }; - place(ui, sub(b, 24.0, 26.0, half - 24.0, FIELD_H), |ui| { - let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &kern_label), half - 24.0); - egui::Popup::menu(&resp).show(|ui| { - for (label, v) in [("Metrics", json!({"kind": "metrics"})), ("Optical", json!({"kind": "optical"})), ("0", json!({"kind": "none"}))] { - if ui.selectable_label(kern_label == label, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run("type.char", json!({"attrs": {"kerning": v}})); - ui.close(); - } - } - }); - }); + place(ui, sub(b, 24.0, 26.0, half - 24.0, FIELD_H), |ui| kerning_control(app, ui, "ckern", &c["kerning"], half - 24.0)); icons::paint(ui.painter(), sub(b, half + 10.0, 27.0, 19.0, 19.0), "tracking", t.icon); const TRACK: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0]; if let Some(v) = place(ui, sub(b, half + 34.0, 26.0, lw, FIELD_H), |ui| { @@ -1277,6 +1260,37 @@ fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { } } +/// Kerning for the text selection: the label shows Metrics, Optical, 0 or the manual value; its +/// menu sets one of the first three or a manual value in 1/1000 em (typed or stepped). +fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) { + let kind = k["kind"].as_str().unwrap_or("metrics"); + let manual = k["value"].as_f64().filter(|v| kind == "manual" && v.is_finite()); + let label = match (kind, manual) { + ("optical", _) => "Optical".to_string(), + ("none", _) => "0".to_string(), + ("manual", Some(v)) => format!("{v}"), + _ => "Metrics".to_string(), + }; + let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &label), width); + let name = crate::i18n::tr(&app.ui.language, "Kerning"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, name)); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + for (text, v) in [("Metrics", json!({"kind": "metrics"})), ("Optical", json!({"kind": "optical"})), ("0", json!({"kind": "none"}))] { + if ui.selectable_label(label == text, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, text))).clicked() { + let _ = app.run("type.char", json!({"attrs": {"kerning": v}})); + ui.close(); + } + } + ui.separator(); + // A manual value: between the selected character and the next, in 1/1000 em. + let field = format!("{id}_manual"); + if let Some(v) = NumField::number(&field, Some(manual.unwrap_or(0.0)), "", 0).width(90.0).spinner().step(5.0).range(-1000.0, 10000.0).show(ui) + { + let _ = app.run("type.char", json!({"attrs": {"kerning": {"kind": "manual", "value": v}}})); + } + }); +} + 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.")); @@ -1329,10 +1343,13 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); } ui.end_row(); + caption(ui, crate::i18n::tr(&app.ui.language, "Kerning")); + kerning_control(app, ui, "pck", &c["kerning"], 60.0); caption(ui, crate::i18n::tr(&app.ui.language, "Tracking")); - if let Some(v) = number(ui, "pct", c["tracking"].as_f64(), "", 60.0, 0) { + if let Some(v) = number(ui, "pct", c["tracking"].as_f64(), "", 70.0, 0) { let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); } + ui.end_row(); caption(ui, crate::i18n::tr(&app.ui.language, "Baseline")); if let Some(v) = number(ui, "pcb", c["baselineShift"].as_f64(), " pt", 70.0, 2) { let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}})); @@ -2306,6 +2323,63 @@ mod tests { assert_eq!(language(h.state_mut()), "French"); } + fn kerning(app: &mut DesignApp) -> Value { + text_attrs(app).unwrap()["chars"]["kerning"].clone() + } + + /// A harness drawing `panel` for `app` (fonts installed before the first frame). + fn panel_harness(app: DesignApp, panel: fn(&mut DesignApp, &mut egui::Ui)) -> Harness<'static, DesignApp> { + Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + panel(app, ui); + }, + app, + ) + } + + fn text_app() -> DesignApp { + 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": "AVATAR", "caret": true})).unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + app + } + + #[test] + fn properties_kerning_menu_sets_zero_kerning() { + let mut h = panel_harness(text_app(), show); + h.run_steps(3); + assert_eq!(kerning(h.state_mut())["kind"], "metrics"); + h.get_by_label("Kerning").click(); + h.run_steps(2); + h.get_by_label("0").click(); + h.run_steps(2); + assert_eq!(kerning(h.state_mut())["kind"], "none"); + } + + #[test] + fn character_panel_has_kerning_and_takes_a_typed_manual_value() { + let mut h = panel_harness(text_app(), character_panel); + h.run_steps(3); + // The caption and the control share the name: click the control. + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Kerning").click(); + h.run_steps(2); + // The menu's field is drawn after the panel's own fields. + let field = h.get_all_by_role(egui::accesskit::Role::TextInput).last().unwrap(); + field.focus(); + field.type_text("-40"); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(kerning(h.state_mut()), json!({"kind": "manual", "value": -40.0})); + } + #[test] fn clicking_a_swatch_chip_in_the_appearance_fill_menu_applies_it() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 6cacf4e8b7764e7e31218f092f2e31341d83bb7c Mon Sep 17 00:00:00 2001 From: ujo4eva <92157639+ujo4eva@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:54:12 +0100 Subject: [PATCH 054/128] Linux: build an Arch package (.pkg.tar.zst) package.sh now also builds designcraft--linux-.pkg.tar.zst with nfpm's archlinux packager, from the same nfpm.yaml. It is named by the bundle id (ai.storyteller.designcraft), provides designcraft and conflicts with designcraft, designcraft-bin and designcraft-git, so an AUR helper doesn't take it over and ArtCraft Launcher can tell it from an AUR build. The README lists it with the other Linux downloads. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + packaging/linux/nfpm.yaml | 20 +++++++++++++++++++- packaging/linux/package.sh | 21 +++++++++++++++------ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0ec6540ba..7268b3d3d 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,7 @@ Installers and executables are code-signed. | Flatpak | `designcraft--linux-x86_64.flatpak` | `designcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | | Debian/Ubuntu | `designcraft--linux-x86_64.deb` | `designcraft--linux-aarch64.deb` | | | Fedora/RHEL/openSUSE | `designcraft--linux-x86_64.rpm` | `designcraft--linux-aarch64.rpm` | | +| Arch Linux/Manjaro | `designcraft--linux-x86_64.pkg.tar.zst` | `designcraft--linux-aarch64.pkg.tar.zst` | `sudo pacman -U `; installs as `ai.storyteller.designcraft`, replacing an AUR package | | Tarball | `designcraft--linux-x86_64.tar.gz` | `designcraft--linux-aarch64.tar.gz` | Unpack anywhere | RISC-V (riscv64): `designcraft--linux-riscv64.tar.gz` only, cross-compiled; needs glibc 2.39+ (Ubuntu 24.04 or newer). diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index e2ce72b10..350db2e0f 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -1,4 +1,4 @@ -# .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages +# .deb/.rpm/Arch packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages # the file tree, substitutes its path for ${STAGE} (nfpm doesn't expand env vars in `src`) and # exports VERSION and NFPM_ARCH. # @@ -53,6 +53,24 @@ overrides: - libXrandr - mesa-vulkan-drivers - xdg-desktop-portal + # Named ai.storyteller.designcraft, not designcraft: the AUR already has designcraft, designcraft-bin and + # designcraft-git, and an AUR helper treats any installed package with an AUR name as its own and + # rebuilds it on the next update. ArtCraft Launcher also uses the bundle id to tell this package + # (which it updates with `pacman -U`) from an AUR build (which it leaves to the AUR helper). + # nfpm has no optdepends for Arch, so the recommends above have no equivalent here. + archlinux: + depends: + - glibc + - gcc-libs + - hicolor-icon-theme + - libxkbcommon + - vulkan-icd-loader + provides: + - designcraft + conflicts: + - designcraft + - designcraft-bin + - designcraft-git contents: - src: ${STAGE}/usr/bin/designcraft diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index fd7fc4ef8..d6391a9cd 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -5,11 +5,12 @@ # $DIST/designcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) # $DIST/designcraft--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ... # $DIST/designcraft--linux-.rpm Fedora, openSUSE, RHEL, ... +# $DIST/designcraft--linux-.pkg.tar.zst Arch, Manjaro, EndeavourOS, ... # $DIST/designcraft--linux-.tar.gz plain FHS-style tree (bin/, share/) # -# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] +# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm arch tar"] # -# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage +# Needs: cargo; nfpm for deb/rpm/arch (https://nfpm.goreleaser.com); appimagetool for the AppImage # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. @@ -20,12 +21,12 @@ HERE="$ROOT/packaging/linux" APP_ID=ai.storyteller.designcraft SKIP_BUILD=0 -FORMATS="appimage deb rpm tar" +FORMATS="appimage deb rpm arch tar" while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -h | --help) sed -n '2,16p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done @@ -85,8 +86,8 @@ if has tar; then echo "wrote $DIST/$BASENAME.tar.gz" fi -# ---- .deb / .rpm -------------------------------------------------------------------------------- -if has deb || has rpm; then +# ---- .deb / .rpm / .pkg.tar.zst ----------------------------------------------------------------- +if has deb || has rpm || has arch; then command -v nfpm >/dev/null || { echo "error: nfpm not found (https://nfpm.goreleaser.com/install/)" >&2; exit 1; } export VERSION export NFPM_ARCH="$DEB_ARCH" @@ -95,6 +96,14 @@ if has deb || has rpm; then for fmt in deb rpm; do if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi done + # The Arch package is named by the bundle id (see `archlinux` in nfpm.yaml), which nfpm can't + # override per format, and `archlinux.packager` isn't env-expanded, so both are written here. + if has arch; then + sed "s|^name: designcraft\$|name: $APP_ID|" "$WORK/nfpm.yaml" >"$WORK/nfpm-arch.yaml" + grep -qx "name: $APP_ID" "$WORK/nfpm-arch.yaml" || { echo "error: couldn't rename the Arch package" >&2; exit 1; } + printf 'archlinux:\n packager: "%s"\n' "$DESIGNCRAFT_MAINTAINER" >>"$WORK/nfpm-arch.yaml" + (cd "$ROOT" && nfpm package -f "$WORK/nfpm-arch.yaml" -p archlinux -t "$DIST/$BASENAME.pkg.tar.zst") + fi fi # ---- AppImage ----------------------------------------------------------------------------------- From c7b3ee4f45983d2f2b45d5a0133fe77117e34c85 Mon Sep 17 00:00:00 2001 From: Govinda Roy Date: Wed, 14 Oct 2026 19:39:37 +0600 Subject: [PATCH 055/128] Layers panel: a new selection opens its layer and scrolls its row into view Selecting an object on the canvas left the Layers panel as it was: its layer could be closed and its row out of view. A new selection now opens the layer it is on and scrolls its row into view once (closing or scrolling afterwards is left alone); selected rows are highlighted, and an object selected inside a group shows as its group. Unnamed image frames are listed by their file name, as InDesign lists placed images. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/layers.rs | 54 ++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index a9c842116..8f5ef35a4 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -13,7 +13,8 @@ const ROW_H: f32 = 24.0; /// Width of the eye and lock columns. const COL_W: f32 = 22.0; -/// Display name of an item: its own name, the first words of a text frame's story, or the kind. +/// Display name of an item: its own name, the first words of a text frame's story, the file a +/// graphic frame shows (as InDesign lists placed images), or the kind. pub fn item_label(doc: &Document, it: &Item) -> String { if !it.name.is_empty() { return it.name.clone(); @@ -41,6 +42,9 @@ pub fn item_label(doc: &Document, it: &Item) -> String { return format!("<{short}{ell}>"); } } + if let Some(name) = it.graphic().and_then(|g| doc.assets.get(&g.asset)).map(|a| a.name.trim()).filter(|n| !n.is_empty()) { + return format!("<{name}>"); + } it.default_label().to_string() } @@ -111,6 +115,15 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { let active = st.active_layer; let selected: Vec = st.selection.items.clone(); let sel_layers: Vec<_> = selected.iter().filter_map(|i| doc.item(*i).map(|it| it.layer)).collect(); + // The rows stand for top-level objects: an object selected inside a group shows as its group. + let shown: Vec = selected.iter().map(|i| doc.top_level_of(*i).unwrap_or(*i)).collect(); + // A new selection (made on the canvas or anywhere else) is revealed: its layer opens and its row + // scrolls into view, once, so the panel can still be scrolled and closed freely afterwards. + let last_id = egui::Id::new("layers_revealed_selection"); + let now: Vec = shown.iter().map(|i| i.0).collect(); + let reveal = !now.is_empty() && ui.data(|d| d.get_temp::>(last_id)).as_ref() != Some(&now); + ui.data_mut(|d| d.insert_temp(last_id, now)); + let mut scrolled = false; // Objects on the current spread, topmost first. let cur = crate::canvas::current_page(app).unwrap_or(0); let spread_items: Vec> = @@ -118,7 +131,11 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.y = 0.0; for l in doc.layers.iter().rev() { let open_id = egui::Id::new(("layer_open", l.id.0)); - let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(l.id == active); + let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(l.id == active); + if reveal && sel_layers.contains(&l.id) && !open { + open = true; + ui.data_mut(|d| d.insert_temp(open_id, true)); + } let lc = c32(l.color); let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); if l.id == active { @@ -201,8 +218,14 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { } for it in spread_items.iter().filter(|i| i.layer == l.id) { let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); - let is_sel = selected.contains(&it.id); - if resp.hovered() { + let is_sel = shown.contains(&it.id); + if is_sel { + ui.painter().rect_filled(row, 0.0, t.row_selected); + if reveal && !scrolled { + resp.scroll_to_me(Some(egui::Align::Center)); + scrolled = true; + } + } else if resp.hovered() { ui.painter().rect_filled(row, 0.0, t.hover); } let (eye, lock) = eye_lock(ui, &app.ui.language, row, !it.hidden, it.locked, !l.visible, ("item", it.id.0)); @@ -267,4 +290,27 @@ mod tests { let rr = doc.item(ItemId(rect["id"].as_u64().unwrap())).unwrap(); assert_eq!(item_label(doc, rr), ""); } + + #[test] + fn selecting_an_object_opens_its_layer_in_the_panel() { + use egui_kittest::Harness; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let first = app.session.active().unwrap().doc.item(ItemId(id)).unwrap().layer; + // A new layer becomes the active one: the first layer's row starts closed. + app.run("layer.new", json!({})).unwrap(); + app.run("selection.set", json!({"ids": []})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(260.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); + h.run_steps(2); + let open = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new(("layer_open", first.0)))).unwrap_or(false); + assert!(!open(&h), "closed while nothing on it is selected"); + h.state_mut().run("selection.set", json!({"ids": [id]})).unwrap(); + h.run_steps(2); + assert!(open(&h), "selecting the object opens its layer"); + // Closed again by hand, it stays closed while the selection stays the same. + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("layer_open", first.0)), false)); + h.run_steps(2); + assert!(!open(&h)); + } } From 44b24d32ba99240d3b919d15c8ace911fcd05a86 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:18:13 +0200 Subject: [PATCH 056/128] Render: wide effect blurs are drawn at a lower resolution (#904) A drop shadow, inner shadow or outer glow with a large size needed gigabytes for one render: the multithreaded pipeline's offscreen image grew by three times the blur on every side, and vello pads its own filter layer by as much again, on single-threaded contexts too. A blur wider than 1,024 device pixels is now drawn offscreen at a lower resolution, where it is that wide, and scaled back up. It looks the same, and both buffers stay close to the window's size. --- crates/render/src/fx.rs | 76 +++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6b29516f4..cc9f717a2 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -4,7 +4,7 @@ //! vello_cpu only supports filter layers in single-threaded contexts, so on the multithreaded //! pipeline each effect is rendered into an offscreen single-threaded context cropped to its //! reach and composited back as an image ([`Renderer::with_filters`]); single-threaded contexts -//! draw directly. +//! draw directly. A very wide blur goes offscreen in both, at a lower resolution. //! //! - Drop shadow / outer glow: the object's silhouette (everything it paints: fill, image, text, //! stroke) in a `DropShadowOnly` layer under the object. `size` sets the blur (σ = size / 2), @@ -337,8 +337,9 @@ impl Renderer { } /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a - /// multithreaded context the layer is drawn into an offscreen single-threaded context - /// covering `reach` (spread space) plus the blur's spread, then composited back. + /// multithreaded context, or for a blur wider than [`MAX_BLUR_SPREAD`], the layer is drawn into + /// an offscreen single-threaded context covering `reach` (spread space) plus the blur's spread, + /// then composited back. pub(crate) fn with_filters( &mut self, ctx: &mut RenderContext, @@ -347,28 +348,67 @@ impl Renderer { sigma: f64, draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), ) { - if !f.mt { - return draw(self, ctx, f); - } // 3σ of the Gaussian in pixels, plus a pixel of antialiasing. let spread = sigma.max(0.0) * 3.0 / f.px + 2.0; - let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread); - let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen); - let (x0, y0) = (r.x0.floor(), r.y0.floor()); - let (w, h) = ((r.x1.ceil() - x0).min(crate::MAX_SIDE as f64), (r.y1.ceil() - y0).min(crate::MAX_SIDE as f64)); - if w < 1.0 || h < 1.0 { - return; + if !f.mt && spread <= MAX_BLUR_SPREAD { + return draw(self, ctx, f); } - let (w, h) = (w as u16, h as u16); - let mut off = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); - let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; + let Some(o) = offscreen(f.view.transform_rect_bbox(reach), spread, ctx.width(), ctx.height()) else { return }; + let mut off = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -o.y0)) * f.view; + let shifted = Frame { mt: false, view, px: f.px * o.scale, ..*f }; draw(self, &mut off, &shifted); off.flush(); - let mut pm = vello_cpu::Pixmap::new(w, h); + let mut pm = vello_cpu::Pixmap::new(o.width, o.height); off.render(&mut pm, &mut self.resources); - ctx.set_transform(Affine::translate((x0, y0))); + ctx.set_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale)); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(pm)), sampler: peniko::ImageSampler::default() }); - ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64)); + ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(o.width), f64::from(o.height))); ctx.set_transform(Affine::IDENTITY); } } + +/// The widest blur (3σ, device pixels) drawn at full resolution. vello pads a filter layer by the +/// blur's width on every side, so a wide blur needs gigabytes; it is drawn at a lower resolution +/// instead, where it is this wide, and has no detail to lose there. +const MAX_BLUR_SPREAD: f64 = 1024.0; + +/// An offscreen image for [`Renderer::with_filters`]: where it goes on the device, how many device +/// pixels one of its pixels covers, and its size. +#[derive(Debug, PartialEq)] +struct Offscreen { + x0: f64, + y0: f64, + scale: f64, + width: u16, + height: u16, +} + +/// The offscreen image an effect is drawn in: `reach` (device pixels) widened by the blur's +/// `spread`, within the window widened the same way, at a resolution that keeps the blur within +/// [`MAX_BLUR_SPREAD`], at most `MAX_SIDE` a side. `None` when none of it shows. +fn offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option { + let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread, spread); + let r = reach.inflate(spread, spread).intersect(screen); + let (x0, y0) = (r.x0.floor(), r.y0.floor()); + let scale = (spread / MAX_BLUR_SPREAD).max(1.0); + let side = |len: f64| (len / scale).ceil().min(crate::MAX_SIDE as f64); + let (w, h) = (side(r.x1.ceil() - x0), side(r.y1.ceil() - y0)); + (w >= 1.0 && h >= 1.0).then_some(Offscreen { x0, y0, scale, width: w as u16, height: h as u16 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_wide_blur_is_drawn_offscreen_at_a_lower_resolution() { + let object = Rect::new(150.0, 150.0, 350.0, 350.0); + let small = Offscreen { x0: 140.0, y0: 140.0, scale: 1.0, width: 220, height: 220 }; + assert_eq!(offscreen(object, 10.0, 1440, 900), Some(small)); + // A 10,000 pt drop shadow at 100%: 3σ is 15,000 px, which asked for 30,204 px a side and as + // much padding again inside vello's filter layer. Drawn at 1/14.65 it is 1,024 px wide. + let wide = Offscreen { x0: -14852.0, y0: -14852.0, scale: 15_002.0 / 1024.0, width: 2062, height: 2062 }; + assert_eq!(offscreen(object, 15_002.0, 1440, 900), Some(wide)); + } +} From 455442615b1c8b7613ab9d702dbaf604a70df5fd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 16:29:10 +0200 Subject: [PATCH 057/128] Color parsing: bare CMYK is always 0..100, so C 1 is a 1 % tint (fixes #910) --- ROADMAP.md | 2 ++ crates/engine/src/cmd/style.rs | 51 +++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..b6e79ba9d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · Colour parameters: a bare `{c,m,y,k}` is always read as 0..100, so C 1 is a 1 % tint and no longer + solid cyan; fractions take the colour's own `{model: "cmyk", …}` form (#910). No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 0280612d1..8cd0a0230 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -135,7 +135,7 @@ pub fn specs() -> Vec { "New Color Swatch…", [], None, - "{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b], spot?: bool}", + "{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b]|{model: cmyk|rgb|gray, …}(0..1), spot?: bool}", has_doc, create_swatch ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Gradient", [], None, - "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}|[r,g,b], opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)", + "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b]|{model, …}(0..1), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)", super::has_selection, apply_gradient ), @@ -180,7 +180,7 @@ pub fn specs() -> Vec { "Apply Color", [], None, - "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)", + "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: cmyk|rgb|gray, …}(0..1), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)", has_text_or_selection, apply_color ), @@ -747,18 +747,20 @@ pub fn parse_color(v: &Value) -> Option { use designcraft_color::Color; match v { Value::String(s) => Color::from_hex(s), - // One scale per colour, not per component: {c: 0.7, y: 12.2} is C 0.7 %, Y 12.2 %. Only a colour whose + // One scale per colour, not per component: [1, 128, 255] is 0..255. Only an RGB array whose // components are all within 0..1 is read as fractions (the older form). Value::Array(a) if a.len() >= 3 => { let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?]; let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 }; Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32)) } + // `{model, …}` is the colour's own form (as `color.convert` takes and returns it): fractions. + Value::Object(o) if o.contains_key("model") => serde_json::from_value(v.clone()).ok(), + // Bare CMYK is always 0..100, as documented: C 1 % is a real tint, so values of 1 or less can't + // mean fractions (#910). Value::Object(o) => { - let g = |k: &str| o.get(k).and_then(Value::as_f64); - let v = [g("c")?, g("m")?, g("y")?, g("k")?]; - let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 }; - Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32)) + let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| (x / 100.0) as f32); + Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?)) } _ => None, } @@ -1415,14 +1417,43 @@ mod color_tests { // {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %. let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") }; assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}"); - // Fractions (every component within 0..1) keep working. - let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") }; + // Fractions take the colour's own form, with its model. + let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"model": "cmyk", "c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") }; assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6); // RGB arrays follow the same rule: [1, 128, 255] is 0..255. let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") }; assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6); } + /// #910: `{c: 1, m: 0, y: 0, k: 0}` is a 1 % cyan tint, not solid cyan, though every component is within 0..1. + #[test] + fn cmyk_percentages_of_one_or_less_stay_percent() { + use designcraft_color::Color; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("swatch.create", &json!({"name": "Tint1", "color": {"c": 1, "m": 0, "y": 0, "k": 0}})).unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "Tint1"})).unwrap(); + let d = &s.doc().unwrap().doc; + let Some(Color::Cmyk { c, m, y, k }) = d.resolve_color("Tint1", 1.0) else { panic!("not CMYK") }; + assert!((c - 0.01).abs() < 1e-6 && m == 0.0 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}"); + let pdf = designcraft_pdf::export_pdf(d, &s.cache, &designcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap(); + let text = String::from_utf8_lossy(&pdf); + // Operands of every `k` (DeviceCMYK fill), last first: [k, y, m, c]. Inks are written in 1/255 steps (C 1 % is 3/255). + let fills: Vec> = + text.split(" k ").filter_map(|before| before.rsplit(' ').take(4).map(|x| x.parse().ok()).collect::>>()).collect(); + let cyan = |want: f32| fills.iter().any(|f| (f[3] - want).abs() < 0.5 / 255.0 + 1e-6 && f[..3] == [0.0; 3]); + assert!(cyan(0.01), "C 1 % in the PDF: {fills:?}"); + assert!(!cyan(1.0), "no solid cyan: {fills:?}"); + // A light tint whose components are all 1 or less, through object.color. + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + s.execute("object.color", &json!({"color": {"c": 0.5, "m": 0.5, "y": 0, "k": 0}})).unwrap(); + let d = &s.doc().unwrap().doc; + let fill = &d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch; + let Some(Color::Cmyk { c, m, .. }) = d.resolve_color(fill, 1.0) else { panic!("not CMYK") }; + assert!((c - 0.005).abs() < 1e-6 && (m - 0.005).abs() < 1e-6, "got {c} {m}"); + } + #[test] fn style_groups_rename_every_use() { let mut s = Session::new(); From b12e94c11d0e302adc20fa8f353e8d0f42fc7b6a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 16:37:55 +0200 Subject: [PATCH 058/128] Package: a missing link stays missing, with no empty file in Links/ (fixes #908) --- crates/engine/src/cmd/package.rs | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 25f09537c..8daa68e4b 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -292,6 +292,11 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset continue; } let Some(a) = d.assets.get(&id) else { continue }; + // A missing link has no bytes: an empty file under its name would read as present, so it + // keeps its old (missing) path instead. + if a.data.is_empty() { + continue; + } let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken); let path = dir.join(&name); root.write(&path, a.data.as_slice())?; @@ -472,6 +477,49 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// A link whose file is gone has no bytes to copy: the package leaves it out of Links/, the + /// report lists it as missing, and the packaged document keeps it missing (#908). + #[test] + fn package_leaves_missing_links_missing() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-missing-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let svg = r##""##; + let (gone, kept) = (dir.join("gone.svg"), dir.join("kept.svg")); + std::fs::write(&gone, svg).unwrap(); + std::fs::write(&kept, svg.replace(" Date: Sat, 10 Oct 2026 19:59:26 -0600 Subject: [PATCH 059/128] Docs: explain hyphenation defaults and image fitting modes to agents Command descriptions left the new-text hyphenation default and image fitting outcomes implicit. State the default and each fitting mode's semantics, and add an MCP example that disables hyphenation. No behavior changes. --- crates/engine/src/cmd/object.rs | 2 +- crates/engine/src/cmd/text.rs | 2 +- docs/mcp.md | 21 +++++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 5e9ed3912..7d7c7fae7 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -586,7 +586,7 @@ pub fn specs() -> Vec { "Fitting", ["Object", "Fitting"], None, - "{mode: fillProportionally|fitProportionally|fitContentToFrame|centerContent|fitFrameToContent, ids?}", + "{mode: fillProportionally (preserves aspect ratio, covers frame, clips overflow)|fitProportionally (preserves aspect ratio, shows whole image, may leave empty space)|fitContentToFrame (stretches content to frame)|centerContent (keeps current size, positions at fitting alignment)|fitFrameToContent (resizes frame to content), ids?}", has_selection, fit ), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0b87820ff..6996a796e 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -148,7 +148,7 @@ pub fn specs() -> Vec { "Paragraph Formatting", [], None, - "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", + "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", has_text_or_frames, |s, p| format_paras(s, p.get("attrs").unwrap_or(p)) ), diff --git a/docs/mcp.md b/docs/mcp.md index f35585746..2eb03e1cc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -71,6 +71,27 @@ and `text.move` return, a story's `length` in `get_story` and `inspect_document` `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. +## Text and image fitting + +Hyphenation is on by default for new text (`hyphenate: true`). To turn it off, select the text or a text frame, +then call `execute`: + +```text +execute {"command": "type.para", "params": {"attrs": {"hyphenate": false}}} +``` + +With a text selection, this formats the selected paragraphs; with selected text frames, it formats their whole stories. + +Use `execute` with `object.fit` to fit a selected graphic frame (or pass `ids`): + +| `mode` | What it does | +|---|---| +| `fillProportionally` | Preserves aspect ratio, covers the frame and clips overflow. | +| `fitProportionally` | Preserves aspect ratio, shows the whole image and may leave empty space. | +| `fitContentToFrame` | Stretches the content to the frame's width and height. | +| `centerContent` | Keeps the current size and positions content at the fitting alignment (center by default). | +| `fitFrameToContent` | Resizes the frame to the content's bounds. | + ## Tools | Tool | What it does | From a0de7a1e07a6f0b788e7ebbbe6a074e1d3d94fbe Mon Sep 17 00:00:00 2001 From: ujo4eva <92157639+ujo4eva@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:41:04 +0100 Subject: [PATCH 060/128] Linux: Arch package without pacman's permission and mtime warnings nfpm (2.47, 2.48) writes Go's directory bit into the mode of each directory a `type: tree` entry walks (0o20000000755), so installing the package warned "directory permissions differ" on every shared directory under /usr/share. It also stamps the files and .MTREE a moment apart, so `pacman -Qkk` reported most files as modified. The Arch build now lists usr/share file by file (nfpm then gives the parent directories a plain 0755) and fixes the time with SOURCE_DATE_EPOCH (the commit time unless set), which also makes the package reproducible. Co-Authored-By: Claude Opus 5.5 --- packaging/linux/package.sh | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index d6391a9cd..2d7b80ee8 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -102,7 +102,29 @@ if has deb || has rpm || has arch; then sed "s|^name: designcraft\$|name: $APP_ID|" "$WORK/nfpm.yaml" >"$WORK/nfpm-arch.yaml" grep -qx "name: $APP_ID" "$WORK/nfpm-arch.yaml" || { echo "error: couldn't rename the Arch package" >&2; exit 1; } printf 'archlinux:\n packager: "%s"\n' "$DESIGNCRAFT_MAINTAINER" >>"$WORK/nfpm-arch.yaml" - (cd "$ROOT" && nfpm package -f "$WORK/nfpm-arch.yaml" -p archlinux -t "$DIST/$BASENAME.pkg.tar.zst") + # nfpm (2.47, 2.48) writes Go's directory bit into the mode of each directory a `type: tree` + # entry walks (0o20000000755), and pacman then warns "directory permissions differ" on every + # shared directory under /usr/share. List the tree's files one by one instead: nfpm gives their + # parent directories a plain 0755. + if find "$STAGE/usr/share" ! -type d ! -type f | grep -q .; then + echo "error: only plain files can be listed in the Arch package's usr/share" >&2; exit 1 + fi + find "$STAGE/usr/share" -type f | LC_ALL=C sort | while IFS= read -r f; do + printf ' - src: "%s"\n dst: "%s"\n' "$f" "${f#"$STAGE"}" + done >"$WORK/arch-share.yaml" + awk -v files="$WORK/arch-share.yaml" -v tree=" - src: $STAGE/usr/share/" ' + $0 == tree { while ((getline line < files) > 0) print line; skip = 2; next } + skip > 0 { skip--; next } + { print } + ' "$WORK/nfpm-arch.yaml" >"$WORK/nfpm-arch.yaml.new" + mv "$WORK/nfpm-arch.yaml.new" "$WORK/nfpm-arch.yaml" + if grep -q 'type: tree' "$WORK/nfpm-arch.yaml"; then + echo "error: couldn't list the Arch package's files" >&2; exit 1 + fi + # Without a fixed time nfpm stamps the files and .MTREE a moment apart, and `pacman -Qkk` + # reports every file as modified. The commit time also makes the package reproducible. + epoch="${SOURCE_DATE_EPOCH:-$(git -C "$ROOT" log -1 --format=%ct 2>/dev/null || date +%s)}" + (cd "$ROOT" && SOURCE_DATE_EPOCH="$epoch" nfpm package -f "$WORK/nfpm-arch.yaml" -p archlinux -t "$DIST/$BASENAME.pkg.tar.zst") fi fi From 52eac14f2c6399ea6c275a43b77ca69307d9e8ff Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 16:48:52 +0200 Subject: [PATCH 061/128] PDF: a placed PDF 1.7 with /Version /1.7 in its catalog goes into PDF/X-4 exports (fixes #907) --- crates/engine/src/cmd/export.rs | 39 +++++++++++++++++++++++++++++++++ crates/pdf/src/export.rs | 31 +++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..ef655b500 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -850,6 +850,45 @@ mod placed_pdf_print_tests { let e = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_err().to_string(); assert!(e.contains("logo.pdf is PDF 2.0") && e.contains("PDF 1.6"), "{e}"); } + + /// A one-page PDF with a black box whose catalog says `/Version /1.7`, as TCPDF writes it. + fn catalog_v17(header: &str) -> Vec { + let content = "0 0 0 1 k 0 0 200 200 re f"; + let mut pdf = format!("{header}\n").into_bytes(); + let mut offsets = Vec::new(); + for body in [ + "<>".to_owned(), + "<>".to_owned(), + "<>>>".to_owned(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ] { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + pdf + } + + /// A placed PDF whose catalog `/Version /1.7` overrides its header failed the PDF/X-4 export + /// with "logo.pdf is PDF 1.7", though a 1.7 header alone is relabelled (#907). + #[test] + fn catalog_version_17_placed_in_pdfx4() { + for header in ["%PDF-1.7", "%PDF-1.4"] { + let pdf = catalog_v17(header); + assert_eq!(hayro_syntax::Pdf::new(pdf.clone()).unwrap().version(), hayro_syntax::PdfVersion::Pdf17); + let mut s = page_with(&pdf); + let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_or_else(|e| panic!("{header}: {e}")); + assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); + let out = base64_decode(r["base64"].as_str().unwrap()); + assert!(out.starts_with(b"%PDF-1.6")); + assert_eq!(hayro_syntax::Pdf::new(out).unwrap().version(), hayro_syntax::PdfVersion::Pdf16); + } + } } #[cfg(test)] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..45b87e6f5 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1172,7 +1172,8 @@ impl Exporter<'_> { /// A placed PDF as krilla embeds it. krilla refuses a PDF newer than the file it writes (the /// export fails at the end with `VersionMismatch(Pdf17)`), and PDF/X-4 is PDF 1.6 while /// VectorCraft and most tools write 1.7. PDF 1.7 changed nothing in how a page draws, so a 1.7 - /// file goes in as 1.6 (its header relabelled, as `qpdf --force-version=1.6` does). A newer + /// file goes in as 1.6 (its header and catalog `/Version` relabelled, as + /// `qpdf --force-version=1.6` does). A newer /// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]). fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option { use hayro_syntax::PdfVersion as V; @@ -1193,8 +1194,8 @@ impl Exporter<'_> { } if have == V::Pdf17 && max == V::Pdf16 - && let Some(bytes) = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6") - && let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(bytes)) + && let bytes = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| asset.data.to_vec()) + && let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(relabel_catalog_version(bytes))) && pdf.version() <= max { self.warn(format!("{}: a PDF 1.7 placed in a PDF 1.6 file, embedded as PDF 1.6", asset.name)); @@ -1360,6 +1361,30 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option> { Some(out) } +/// `data` with every `/Version /1.7` changed to `/Version /1.6` in place, so the xref offsets +/// hold. The catalog's `/Version` wins over the header (TCPDF writes both), so relabelling the +/// header alone leaves the file a PDF 1.7. A catalog inside a compressed object stream is left as +/// it is, and the version check still names the file. +fn relabel_catalog_version(mut data: Vec) -> Vec { + const KEY: &[u8] = b"/Version"; + let mut from = 0; + while let Some(at) = data.get(from..).and_then(|rest| rest.windows(KEY.len()).position(|w| w == KEY)) { + let mut i = from + at + KEY.len(); + while data.get(i).is_some_and(|b| b" \t\r\n\x0c\0".contains(b)) { + i += 1; + } + let ends = data.get(i + 4).is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'.'); + if data.get(i..i + 4) == Some(b"/1.7".as_slice()) + && ends + && let Some(b) = data.get_mut(i + 3) + { + *b = b'6'; + } + from = i; + } + data +} + /// The sRGB profile (ICC v2.1) that untagged RGB images carry in PDF/X-4. pub(crate) static SRGB_ICC: &[u8] = include_bytes!("../../../assets/icc/sRGB-v2-magic.icc"); From e9bda1f2d9b989fec8bc52e2b58bbfefef281b4c Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:01:41 +0300 Subject: [PATCH 062/128] idml: cap IDML package part size on import instead of reading unbounded --- crates/idml/src/import.rs | 53 +++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 1a4fa65e3..c15f83551 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -60,6 +60,28 @@ pub struct Imported { pub warnings: Vec, } +/// The most one package part may decompress to. The size an archive claims only bounds the +/// reservation, never the read: a small entry can inflate to gigabytes. +pub(crate) const MAX_PART_BYTES: u64 = 512 * 1024 * 1024; + +/// Entry `index` of `zip` as (name, decompressed bytes); `None` for a directory. An entry that +/// decompresses to more than `limit` bytes is an error rather than an unbounded read. +fn read_part(zip: &mut zip::ZipArchive>, index: usize, limit: u64) -> Result)>> { + let f = zip.by_index(index).map_err(|e| IdmlError::NotIdml(e.to_string()))?; + if f.is_dir() { + return Ok(None); + } + let name = f.name().to_string(); + // The size is what the archive claims: don't reserve more than a sane part up front. + let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24)); + // Read one byte past the limit to tell "exactly at the limit" from "over it". + f.take(limit.saturating_add(1)).read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?; + if buf.len() as u64 > limit { + return Err(IdmlError::Part { part: name, msg: format!("larger than {} MiB uncompressed", limit / (1024 * 1024)) }); + } + Ok(Some((name, buf))) +} + /// Import with a custom reader for linked files, reporting the problems that did not stop it. pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option>) -> Result { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?; @@ -67,14 +89,7 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option = HashMap::new(); for i in 0..zip.len() { - let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?; - if f.is_dir() { - continue; - } - let name = f.name().to_string(); - // The size is what the archive claims: don't reserve more than a sane part up front. - let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24)); - f.read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?; + let Some((name, buf)) = read_part(&mut zip, i, MAX_PART_BYTES)? else { continue }; lenient.entry(normalize_part_name(&name)).or_insert_with(|| name.clone()); files.insert(name, buf); } @@ -3004,3 +3019,25 @@ mod frame_preference_scalar_tests { assert_eq!(Prefs(&[&exact]).prop("ColumnRuleStrokeColor").as_deref(), Some(" Color/Named ")); } } + +#[cfg(test)] +mod part_size_tests { + use std::io::Write; + + use super::*; + + /// A small package part can inflate to gigabytes; the read is capped, so an oversized entry + /// is an error instead of an unbounded read. + #[test] + fn an_oversized_part_is_an_error_not_a_huge_read() { + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + w.start_file("Stories/Story_big.xml", deflated).unwrap(); + w.write_all(&vec![b' '; 64 * 1024]).unwrap(); + let bytes = w.finish().unwrap().into_inner(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + assert!(matches!(read_part(&mut zip, 0, 4096), Err(IdmlError::Part { .. }))); + // At or under the cap reads fine: the same entry with a 64 KiB limit. + assert_eq!(read_part(&mut zip, 0, 64 * 1024).unwrap().map(|(_, b)| b.len()), Some(64 * 1024)); + } +} From 8ce1292d0e81af43127976278db539356901bca3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 17:04:51 +0200 Subject: [PATCH 063/128] Footnotes: Span Footnotes Across Columns sets notes across the frame (fixes #915) --- crates/compose/src/lib.rs | 15 +++++++++- crates/compose/src/notes.rs | 59 ++++++++++++++++++++++++++++++++----- crates/compose/src/tests.rs | 38 ++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 105 insertions(+), 9 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c6312e9f5..320519e60 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1021,7 +1021,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // Footnotes referenced on this line need room at the bottom of the column too // (a line at the top of a column is set anyway). let line_notes = if notes.active() { notes.refs_in(line_glyphs) } else { Vec::new() }; - let col_w = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)].width(); + let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.width()); let reserve = notes.reserve(doc, cur.fi, cur.col, &line_notes, col_w, f, opts); let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts) } else { reserve }; let fits = baseline + desc <= col.y1 - reserve + 0.01 && !capped; @@ -1169,9 +1169,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C keep_violation: false, drop_cap: drop_box, }); + let placed_notes = !line_notes.is_empty(); for k in line_notes { notes.place(doc, k, cur.fi, cur.col, col_w, f, opts); } + // Notes spanning the columns take room from the columns before this one too: re-lay + // the frame with that room left. + if placed_notes { + let r = notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts); + if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r) + && let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para) + { + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = j; + continue 'paras; + } + } if !sub_objects.is_empty() { let ft = &mut out.frames[cur.fi]; let li = ft.lines.len() - 1; diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index a37c1aa49..19138a460 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -5,6 +5,9 @@ //! the first footnote and the space between footnotes). A line that would collide moves to the //! next column with its footnotes. After the story is laid out the footnotes of each column are //! stacked against the column bottom, with the separator rule above the first one. +//! +//! With Span Footnotes Across Columns the notes of a frame share one area across all its columns, +//! composed at the frame's text width: every column of the frame makes room for it. use std::collections::HashMap; use std::ops::Range; @@ -38,13 +41,39 @@ pub(crate) struct Notes { /// Composed footnote text by (index, label, width). cache: HashMap<(usize, String, u64), (Arc, f64)>, notes: Vec>, + /// Footnotes span the columns of their frame. + span: bool, + /// Room a frame's earlier columns had to leave for spanning notes placed in a later column; + /// kept across re-lays so the columns before the reference make room on the next pass. + floor: HashMap, } impl Notes { pub fn new(doc: &Document, story: &Story) -> Notes { let refs: Vec = if story.notes.is_empty() { Vec::new() } else { story.text.match_indices(FOOTNOTE_REF).map(|(i, _)| i).collect() }; let start = if refs.is_empty() { 1 } else { doc.footnote_start(story.id) }; - Notes { numbers: vec![start; refs.len()], refs, num: start, key: None, placed: Vec::new(), cache: HashMap::new(), notes: story.notes.clone() } + Notes { + numbers: vec![start; refs.len()], + refs, + num: start, + key: None, + placed: Vec::new(), + cache: HashMap::new(), + notes: story.notes.clone(), + span: doc.footnote_options.span_columns, + floor: HashMap::new(), + } + } + + /// Whether a note placed in (fi, col) shares the footnote area of column (`fi`, `ci`). + fn same_area(&self, p: &Placed, fi: usize, ci: usize) -> bool { + p.fi == fi && (self.span || p.col == ci) + } + + /// The footnote area of column `col` of a frame with columns `cols`: the column, or all of + /// them when notes span columns. + pub fn area(&self, cols: &[Rect], col: usize) -> Option { + if self.span { cols.iter().copied().reduce(|a, b| a.union(b)) } else { cols.get(col.min(cols.len().saturating_sub(1))).copied() } } pub fn active(&self) -> bool { @@ -127,7 +156,7 @@ impl Notes { let o = &doc.footnote_options; let mut n = 0usize; let mut h = 0.0; - for p in self.placed.iter().filter(|p| p.fi == fi && p.col == col) { + for p in self.placed.iter().filter(|p| self.same_area(p, fi, col)) { n += 1; h += p.h; } @@ -135,7 +164,23 @@ impl Notes { n += 1; h += self.compose(doc, k, w, f, opts).1; } - if n == 0 { 0.0 } else { h + o.space_between * (n - 1) as f64 + o.space_before } + let r = if n == 0 { 0.0 } else { h + o.space_between * (n - 1) as f64 + o.space_before }; + if self.span { r.max(self.floor.get(&fi).copied().unwrap_or(0.0)) } else { r } + } + + /// After spanning notes were placed in column `col` of frame `fi`: whether a line of an earlier + /// column now runs into their area. The area is then remembered for the frame, to re-lay it. + pub fn earlier_columns_collide(&mut self, lines: &[crate::Line], fi: usize, col: usize, cols: &[Rect], reserve: f64) -> bool { + if !self.span || col == 0 || reserve <= self.floor.get(&fi).copied().unwrap_or(0.0) + 0.01 { + return false; + } + let hit = lines + .iter() + .any(|l| (l.column as usize) < col && cols.get(l.column as usize).is_some_and(|c| l.baseline + l.descent > c.y1 - reserve + 0.01)); + if hit { + self.floor.insert(fi, reserve); + } + hit } pub fn place(&mut self, doc: &Document, k: usize, fi: usize, col: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) { @@ -152,13 +197,13 @@ impl Notes { return; } let o = &doc.footnote_options; - let mut groups: Vec<(usize, usize)> = self.placed.iter().map(|p| (p.fi, p.col)).collect(); + let mut groups: Vec<(usize, usize)> = self.placed.iter().map(|p| (p.fi, if self.span { 0 } else { p.col })).collect(); groups.dedup(); groups.sort_unstable(); groups.dedup(); for (fi, ci) in groups { - let (Some(_), Some(col)) = (frames.get(fi), cols.get(fi).and_then(|c| c.get(ci))) else { continue }; - let here: Vec<&Placed> = self.placed.iter().filter(|p| p.fi == fi && p.col == ci).collect(); + let (Some(_), Some(col)) = (frames.get(fi), cols.get(fi).filter(|c| ci < c.len()).and_then(|c| self.area(c, ci))) else { continue }; + let here: Vec<&Placed> = self.placed.iter().filter(|p| self.same_area(p, fi, ci)).collect(); let total: f64 = here.iter().map(|p| p.h).sum::() + o.space_between * (here.len() - 1) as f64; let top = col.y1 - total; let ft = &mut out.frames[fi]; @@ -177,7 +222,7 @@ impl Notes { ft.notes.push(PlacedNote { id: note.id, index: p.k, - column: ci as u32, + column: p.col as u32, label: o.label(self.numbers[p.k], true), origin: Point::new(col.x0, y), rect: Rect::new(col.x0, y, col.x1, y + p.h), diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a20ec78b3..432437fd8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1269,6 +1269,44 @@ fn footnote_line_at_column_top_still_sets() { assert_eq!(cs.frames[0].notes.len(), 1); } +#[test] +fn footnotes_span_columns_across_the_frame_bottom() { + // Span Footnotes Across Columns: the note area runs under every column of the frame and each + // column's text ends above it, also the columns before the one holding the reference. + let text = [LOREM; 8].join(" "); + let note = "A long footnote, long enough to wrap if it is set one column wide rather than across the frame."; + let compose = |at: usize, span: bool| { + let (mut d, sid, fid) = doc_with(&text, Rect::new(36.0, 36.0, 436.0, 236.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.footnote_options.span_columns = span; + d.story_mut(sid).unwrap().insert_note(at, note, ParaFormat::default()); + let space_before = d.footnote_options.space_before; + (compose_story(&d, sid, &ComposeOptions::default()), space_before) + }; + let plain = compose(10, false).0; + // A reference halfway down the second column. + let in_col1 = plain.frames[0].lines.iter().filter(|l| l.column == 1).nth(5).unwrap().range.start + 5; + for (at, ref_col) in [(10, 0), (in_col1, 1)] { + let (cs, space_before) = compose(at, true); + let ft = &cs.frames[0]; + let (c0, c1) = (ft.columns[0], ft.columns[1]); + assert_eq!(ft.notes.len(), 1); + let n = &ft.notes[0]; + assert_eq!(n.column, ref_col, "ref at {at}"); + assert!((n.rect.x0 - c0.x0).abs() < 1e-6 && (n.rect.x1 - c1.x1).abs() < 1e-6, "ref at {at}: note {:?} spans the frame", n.rect); + assert!((n.rect.y1 - c0.y1).abs() < 1e-6, "ref at {at}: note ends at the frame bottom"); + for col in 0..2 { + let last = ft.lines.iter().rfind(|l| l.column == col).unwrap(); + assert!(last.baseline + last.descent <= n.rect.y0 - space_before + 0.01, "ref at {at}: column {col} text clears the note"); + } + assert!(n.text.frames[0].lines.iter().all(|l| l.x1 - l.x0 > c0.width() + 1.0), "ref at {at}: note set at the frame width"); + } + // Off: the note stays in the column holding its reference. + let n = &compose(in_col1, false).0.frames[0].notes[0]; + assert_eq!(n.column, 1); + assert!((n.rect.x0 - plain.frames[0].columns[1].x0).abs() < 1e-6 && (n.rect.x1 - plain.frames[0].columns[1].x1).abs() < 1e-6); +} + #[test] fn hj_severity_shades() { use crate::hj_severity; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index daaa3f44c..d6b5bddd1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -277,7 +277,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. -- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. +- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room, or across all the columns of the frame with Span Footnotes Across Columns; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. - Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. From 179a6c5f4efd9fdeedbd76752ba54e557265ba6a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 03:54:51 +0000 Subject: [PATCH 064/128] Tools panel: Selection is the filled arrow, Direct Selection the hollow one Fixes #437 --- crates/ui-egui/src/icons.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c93464374..225ca5172 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -78,8 +78,8 @@ fn arrow(pen: &Pen, hollow: bool) { pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { let mut pen = Pen { p, r, c, w: 1.3 }; match name { - "tool-selection" => arrow(&pen, true), - "tool-direct" => arrow(&pen, false), + "tool-selection" => arrow(&pen, false), + "tool-direct" => arrow(&pen, true), "tool-page" => { pen.closed(&[(5.0, 3.0), (12.0, 3.0), (15.0, 6.0), (15.0, 17.0), (5.0, 17.0)]); pen.line(&[(12.0, 3.0), (12.0, 6.0), (15.0, 6.0)]); @@ -897,4 +897,20 @@ mod tests { assert_ne!(drawn[0], drawn[2], "Shear draws like Free Transform"); assert_ne!(drawn[1], drawn[2], "Scale and Shear draw alike"); } + + /// Whether the arrow icon `name` paints is filled, from the fill of its one path shape. + fn arrow_filled(name: &str) -> bool { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + paint(ui.painter(), Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 20.0)), name, Color32::WHITE); + }); + out.textures_delta.clear(); + out.shapes.iter().any(|s| matches!(&s.shape, Shape::Path(p) if p.fill != Color32::TRANSPARENT)) + } + + #[test] + fn selection_arrow_is_filled_and_direct_selection_hollow() { + assert!(arrow_filled("tool-selection"), "Selection draws a hollow arrow"); + assert!(!arrow_filled("tool-direct"), "Direct Selection draws a filled arrow"); + } } From 78340e6588d0668945009118bbd992231870c9ff Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 04:12:43 +0000 Subject: [PATCH 065/128] Canvas: tools show their own cursors (Direct Selection, Pen states) Tool cursors are glyphs drawn in code (black ink, white halo): the filled arrow for Selection, the hollow arrow for Direct Selection, the pen nib with an add, delete, close or convert badge, and a curved double arrow for rotating. Linux and Windows show them as OS cursor bitmaps, rasterized per pixels-per-point and cached; macOS paints them at the pointer, since it sizes cursor bitmaps in points; the web build keeps the system cursors. Text, hand, zoom and resize cursors stay system cursors. The Pen tool reports what a click does: over the first anchor of the path being drawn it closes the path, and between paths a click on a selected path's anchor deletes it and a click on its outline adds one. The Add, Delete and Convert Direction Point tools show the matching pen badges. The glyph geometry and the rasterizer are adapted from VectorCraft's code-drawn tool cursors (same organisation, original work). Fixes #438 --- crates/tools/src/anchors.rs | 38 ++-- crates/tools/src/lib.rs | 8 + crates/tools/src/pen.rs | 87 ++++++-- crates/tools/src/tests.rs | 50 +++++ crates/ui-egui/src/canvas.rs | 56 ++--- crates/ui-egui/src/cursors.rs | 372 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 + 7 files changed, 552 insertions(+), 63 deletions(-) create mode 100644 crates/ui-egui/src/cursors.rs diff --git a/crates/tools/src/anchors.rs b/crates/tools/src/anchors.rs index bdcf3b273..3ce10b159 100644 --- a/crates/tools/src/anchors.rs +++ b/crates/tools/src/anchors.rs @@ -34,6 +34,16 @@ fn targets(cx: &ToolContext, p: Point) -> Vec { if cx.selection.items.is_empty() { cx.hit(p).map(|(_, id)| vec![id]).unwrap_or_default() } else { cx.selection.items.clone() } } +/// The first of `ids` whose outline passes within 5 screen pixels of canvas point `p`. +pub(crate) fn segment_at(cx: &ToolContext, ids: &[ItemId], p: Point) -> Option { + ids.iter().copied().find(|&id| { + let (Some(it), Some(xf)) = (cx.doc.item(id), cx.item_canvas_xf(id)) else { return false }; + let inner = (xf * it.xf).inverse() * p; + let scale = (xf * it.xf).inverse().determinant().abs().sqrt(); + it.path.nearest(inner).is_some_and(|n| n.4 <= cx.tol(5.0) * scale) + }) +} + impl Tool for AnchorTool { fn id(&self) -> &'static str { match self.kind { @@ -49,21 +59,12 @@ impl Tool for AnchorTool { match (self.kind, ev.kind) { (Kind::Add | Kind::Scissors, PointerKind::Down) => { let Some((_, sp)) = cx.layout.spread_at(p) else { return vec![] }; - // The first target whose outline passes near the pointer. - for id in targets(cx, p) { - let Some(it) = cx.doc.item(id) else { continue }; - let Some(xf) = cx.item_canvas_xf(id) else { continue }; - let inner = (xf * it.xf).inverse() * p; - let scale = (xf * it.xf).inverse().determinant().abs().sqrt(); - if it.path.nearest(inner).is_some_and(|n| n.4 <= cx.tol(5.0) * scale) { - return vec![if self.kind == Kind::Scissors { - Action::Exec("path.split".into(), json!({"id": id.0, "at": [sp.x, sp.y]})) - } else { - Action::Exec("path.addAnchor".into(), json!({"id": id.0, "at": [sp.x, sp.y], "tolerance": cx.tol(5.0)})) - }]; - } - } - vec![] + let Some(id) = segment_at(cx, &targets(cx, p), p) else { return vec![] }; + vec![if self.kind == Kind::Scissors { + Action::Exec("path.split".into(), json!({"id": id.0, "at": [sp.x, sp.y]})) + } else { + Action::Exec("path.addAnchor".into(), json!({"id": id.0, "at": [sp.x, sp.y], "tolerance": cx.tol(5.0)})) + }] } (Kind::Delete, PointerKind::Down) => match anchor_at_in(cx, &targets(cx, p), p) { Some((id, si, ai, None)) => vec![Action::Exec("path.deleteAnchor".into(), json!({"id": id, "subpath": si, "anchor": ai}))], @@ -100,7 +101,12 @@ impl Tool for AnchorTool { } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - if self.kind == Kind::Scissors { Cursor::Crosshair } else { Cursor::Pen } + match self.kind { + Kind::Add => Cursor::PenAdd, + Kind::Scissors => Cursor::Crosshair, + Kind::Delete => Cursor::PenDelete, + Kind::Convert => Cursor::PenConvert, + } } fn busy(&self) -> bool { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 8dbc262ba..1ea912d1d 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -348,8 +348,16 @@ pub enum Cursor { ResizeNeSw, Rotate, Pen, + /// The pen over a segment of a selected path: a click adds an anchor there. + PenAdd, + /// The pen over an anchor of a selected path: a click deletes it. + PenDelete, + /// The pen over the first anchor of the path it is drawing: a click closes it. + PenClose, /// The Pen over an open path's end point: a click continues the path. PenContinue, + /// Convert Direction Point. + PenConvert, /// Over a type-on-a-path bracket: dragging it moves where the text starts or ends. PathBracket, Text, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 21e90f734..6fe1289b5 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -1,12 +1,13 @@ -//! Pen tool (P): click for corner points, drag for smooth points, click the first point to close, -//! Enter/Esc (or switching tools) to finish an open path. Each anchor is its own undo step, as in //! InDesign. Clicking an end point of an open path (the cursor shows it) continues that path from -//! there; dragging from it first reshapes its direction lines. +//! there; dragging from it first reshapes its direction lines. Between paths, a click on a selected +//! path's anchor deletes it and a click on its outline adds an anchor; the cursor shows which. use designcraft_doc::{ItemId, SpreadRef}; use designcraft_geom::{BezPath, Point}; use serde_json::{Value, json}; +use crate::anchors::segment_at; +use crate::select::anchor_at_in; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, spread_json}; #[derive(Default)] @@ -65,7 +66,40 @@ fn anchor_json(a: &(Point, Point, Point)) -> Value { json!({"p": [a.0.x, a.0.y], "in": [a.1.x, a.1.y], "out": [a.2.x, a.2.y]}) } +/// What a pen click at the pointer does instead of placing an anchor. +#[derive(Debug, PartialEq)] +enum Hover { + Place, + /// Close the path being drawn (the pointer is on its first anchor). + Close, + /// Delete anchor (subpath, anchor) of a selected path. + Delete(u64, usize, usize), + /// Add an anchor on a selected path's outline. + Add(ItemId), +} + impl PenTool { + /// What a click at canvas point `p` does. + fn hover_at(&self, cx: &ToolContext, p: Point) -> Hover { + if let Some(sr) = self.spread { + if self.anchors.len() >= 2 + && let Some(first) = self.anchors.first() + && (first.0 - cx.layout.to_spread(sr, p)).hypot() <= cx.tol(6.0) + { + return Hover::Close; + } + return Hover::Place; + } + if !self.anchors.is_empty() { + return Hover::Place; + } + let ids = &cx.selection.items; + if let Some((id, si, ai, None)) = anchor_at_in(cx, ids, p) { + return Hover::Delete(id, si, ai); + } + segment_at(cx, ids, p).map_or(Hover::Place, Hover::Add) + } + fn finish(&mut self) -> Vec { self.path = None; self.spread = None; @@ -159,23 +193,28 @@ impl Tool for PenTool { vec![] } PointerKind::Down => { - // Close on the first anchor. - if self.anchors.len() >= 2 - && let Some(first) = self.anchors.first() - && (first.0 - sp).hypot() <= cx.tol(6.0) - { - let out = match self.path { - Some(id) => { - let mut p = json!({"id": id}); - if let Some(si) = self.subpath { - p["subpath"] = json!(si); + match self.hover_at(cx, ev.pos) { + Hover::Close => { + let out = match self.path { + Some(id) => { + let mut p = json!({"id": id}); + if let Some(si) = self.subpath { + p["subpath"] = json!(si); + } + vec![Action::Exec("path.close".into(), p)] } - vec![Action::Exec("path.close".into(), p)] - } - None => vec![], - }; - self.finish(); - return out; + None => vec![], + }; + self.finish(); + return out; + } + Hover::Delete(id, si, ai) => { + return vec![Action::Exec("path.deleteAnchor".into(), json!({"id": id, "subpath": si, "anchor": ai}))]; + } + Hover::Add(id) => { + return vec![Action::Exec("path.addAnchor".into(), json!({"id": id.0, "at": [sp.x, sp.y], "tolerance": cx.tol(5.0)}))]; + } + Hover::Place => {} } // On an open path's end point: continue that path. if self.anchors.is_empty() @@ -262,7 +301,15 @@ impl Tool for PenTool { } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { - if self.anchors.is_empty() && endpoint_at(cx, p).is_some() { Cursor::PenContinue } else { Cursor::Pen } + if self.anchors.is_empty() && endpoint_at(cx, p).is_some() { + return Cursor::PenContinue; + } + match self.hover_at(cx, p) { + Hover::Place => Cursor::Pen, + Hover::Close => Cursor::PenClose, + Hover::Delete(..) => Cursor::PenDelete, + Hover::Add(_) => Cursor::PenAdd, + } } fn busy(&self) -> bool { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6df86485a..5884f74a4 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1159,3 +1159,53 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))]); } + +#[test] +fn pen_cursor_shows_add_delete_and_close() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "", ParaFormat::default()).unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let selected = Selection::items(vec![fid]); + let none = Selection::default(); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y); + let mut t = create("pen"); + let m = Mods::default(); + + let cx = ctx(&d, &selected, &c, &l); + assert_eq!(t.cursor(&cx, at(200.0, 100.0), m), Cursor::PenDelete, "on a selected path's anchor"); + assert_eq!(t.cursor(&cx, at(150.0, 101.0), m), Cursor::PenAdd, "on a selected path's outline"); + assert_eq!(t.cursor(&cx, at(150.0, 150.0), m), Cursor::Pen, "inside, away from the outline"); + let click = |t: &mut Box, cx: &ToolContext, p: Point| { + let down = t.pointer(cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); + let up = t.pointer(cx, &PointerEvent::new(PointerKind::Up, p.x, p.y)); + (down, up) + }; + let (down, up) = click(&mut t, &cx, at(200.0, 100.0)); + assert_eq!(down, vec![Action::Exec("path.deleteAnchor".into(), serde_json::json!({"id": fid.0, "subpath": 0, "anchor": 1}))]); + assert!(up.is_empty() && !t.busy(), "deleting starts no path"); + let (down, _) = click(&mut t, &cx, at(150.0, 101.0)); + assert!(matches!(&down[..], [Action::Exec(cmd, p)] if cmd == "path.addAnchor" && p["id"] == fid.0), "{down:?}"); + + // Unselected paths are drawn over; the first anchor of the path being drawn closes it. + let cx = ctx(&d, &none, &c, &l); + assert_eq!(t.cursor(&cx, at(200.0, 100.0), m), Cursor::Pen, "unselected path"); + click(&mut t, &cx, at(300.0, 300.0)); + assert_eq!(t.cursor(&cx, at(300.0, 300.0), m), Cursor::Pen, "one anchor cannot close yet"); + click(&mut t, &cx, at(400.0, 300.0)); + assert_eq!(t.cursor(&cx, at(301.0, 301.0), m), Cursor::PenClose, "on the first anchor"); + assert_eq!(t.cursor(&cx, at(350.0, 350.0), m), Cursor::Pen, "elsewhere while drawing"); +} + +#[test] +fn anchor_tools_show_pen_state_cursors() { + let d = Document::new(&NewDocument::default()); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let p = Point::new(10.0, 10.0); + let cursor = |id: &str| create(id).cursor(&cx, p, Mods::default()); + assert_eq!(cursor("addAnchor"), Cursor::PenAdd); + assert_eq!(cursor("deleteAnchor"), Cursor::PenDelete); + assert_eq!(cursor("convertDirection"), Cursor::PenConvert); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5d8218b77..77ed1875d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -348,7 +348,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } else { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) }; - ui.ctx().set_cursor_icon(cursor_icon(c)); + let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p); + ui.ctx().set_cursor_icon(icon); draw_cursor_badge(&painter, p, c, &t); } } @@ -382,32 +383,6 @@ fn draw_cursor_badge(painter: &egui::Painter, p: Pos2, c: Cursor, t: &Tokens) { } } -fn cursor_icon(c: Cursor) -> egui::CursorIcon { - use egui::CursorIcon as C; - match c { - Cursor::Arrow | Cursor::ArrowHollow => C::Default, - Cursor::Move => C::Move, - Cursor::Crosshair => C::Crosshair, - Cursor::ResizeH => C::ResizeHorizontal, - Cursor::ResizeV => C::ResizeVertical, - Cursor::ResizeNwSe => C::ResizeNwSe, - Cursor::ResizeNeSw => C::ResizeNeSw, - Cursor::Rotate => C::Alias, - Cursor::Pen | Cursor::PenContinue => C::Crosshair, - Cursor::PathBracket => C::ResizeColumn, - Cursor::Text => C::Text, - Cursor::Hand => C::Grab, - Cursor::HandGrab => C::Grabbing, - Cursor::ZoomIn => C::ZoomIn, - Cursor::ZoomOut => C::ZoomOut, - Cursor::Eyedropper => C::Crosshair, - Cursor::LoadedText | Cursor::LoadedGraphic => C::Copy, - Cursor::ThreadLink => C::Alias, - Cursor::Unthread => C::NoDrop, - Cursor::NotAllowed => C::NotAllowed, - } -} - /// Keep the canvas texture current. Panning inside the overscanned region just shifts the /// texture; zooming shows the old texture scaled until the background render arrives; document /// edits render synchronously so editing never lags. @@ -2088,6 +2063,33 @@ mod tests { assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h)); } + #[test] + fn tools_show_their_own_cursors_over_the_page() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut h = harness(app); + let p = h.state().canvas_rect.unwrap().center(); + let mut shown = |tool: &str| { + h.state_mut().run("tool.select", json!({"tool": tool})).unwrap(); + h.hover_at(p); + h.run_steps(2); + let out = &h.output().platform_output; + (out.cursor_icon, out.cursor_image.clone()) + }; + let (pen, direct, text) = (shown("pen"), shown("directSelection"), shown("type")); + let ppp = h.ctx.pixels_per_point(); + let images = &mut h.state_mut().cursors; + match crate::cursors::MODE { + crate::cursors::Mode::Os => { + assert_eq!(pen.1, images.get(Cursor::Pen, ppp), "Pen"); + assert_eq!(direct.1, images.get(Cursor::ArrowHollow, ppp), "Direct Selection"); + assert_eq!(text, (egui::CursorIcon::Text, None), "the I-beam stays a system cursor"); + } + crate::cursors::Mode::Painted => assert_eq!((pen.0, direct.0), (egui::CursorIcon::None, egui::CursorIcon::None)), + crate::cursors::Mode::System => assert_eq!(pen, (egui::CursorIcon::Crosshair, None)), + } + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs new file mode 100644 index 000000000..15e63a732 --- /dev/null +++ b/crates/ui-egui/src/cursors.rs @@ -0,0 +1,372 @@ +//! Tool cursors drawn in code: black glyphs with a white halo, so they read on any page colour. +//! The Selection tool's filled arrow, Direct Selection's hollow arrow, the pen nib with a badge for +//! what a click does (add, delete, close, convert), and a curved double arrow for rotating. +//! Cursors without a glyph stay system cursors. +//! +//! Linux and Windows show the glyphs as OS cursors ([`Images`]): the system moves them itself, so +//! they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show +//! twice as big, or blurred), so there the canvas paints the glyph at the pointer. The +//! web build keeps the system cursors. + +use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex}; +use egui::{Color32, CustomCursorImage, Pos2, Shape, Stroke, Vec2, pos2, vec2}; + +use designcraft_tools::Cursor; + +const INK: Color32 = Color32::BLACK; +const HALO: Color32 = Color32::WHITE; + +/// How the window shows a cursor glyph. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Mode { + /// As an OS cursor bitmap ([`Images`]). + Os, + /// Painted into the window at the pointer, the OS cursor hidden. + Painted, + /// Not at all: the system cursor stands in. + System, +} + +pub const MODE: Mode = if cfg!(target_arch = "wasm32") { + Mode::System +} else if cfg!(target_os = "macos") { + Mode::Painted +} else { + Mode::Os +}; + +/// The shapes of one glyph: every halo goes under all the ink, so one part's halo never cuts +/// through another part's ink. +#[derive(Default)] +struct Ink { + halo: Vec, + ink: Vec, +} + +impl Ink { + fn halo(&mut self, s: Shape) { + self.halo.push(s); + } + + fn add(&mut self, s: Shape) { + self.ink.push(s); + } + + /// A filled outline with a halo around it. + fn poly(&mut self, pts: Vec, fill: Color32) { + self.halo(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO))); + self.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE)); + self.add(Shape::closed_line(pts, Stroke::new(1.0, INK))); + } + + /// A haloed stroke. + fn line(&mut self, a: Pos2, b: Pos2) { + self.halo(Shape::line_segment([a, b], Stroke::new(3.0, HALO))); + self.add(Shape::line_segment([a, b], Stroke::new(1.2, INK))); + } + + fn into_shapes(self) -> Vec { + let mut out = self.halo; + out.extend(self.ink); + out + } +} + +fn at(o: Pos2, pts: &[(f32, f32)]) -> Vec { + pts.iter().map(|&(x, y)| o + vec2(x, y)).collect() +} + +/// The pointer arrow, tip at `o`: ink-filled (Selection) or hollow (Direct Selection). +fn arrow(ink: &mut Ink, o: Pos2, hollow: bool) { + let pts = at(o, &[(0.0, 0.0), (0.0, 15.0), (3.8, 11.4), (6.4, 17.0), (8.6, 16.0), (6.1, 10.6), (11.0, 10.6)]); + ink.halo(Shape::closed_line(pts.clone(), Stroke::new(3.0, HALO))); + // The arrow is concave: fill it as two convex parts, then outline it. + let fill = if hollow { HALO } else { INK }; + let part = |idx: &[usize]| idx.iter().filter_map(|&i| pts.get(i).copied()).collect::>(); + ink.add(Shape::convex_polygon(part(&[0, 1, 2, 5, 6]), fill, Stroke::NONE)); + ink.add(Shape::convex_polygon(part(&[2, 3, 4, 5]), fill, Stroke::NONE)); + ink.add(Shape::closed_line(pts, Stroke::new(1.0, INK))); +} + +/// What the pen badge says a click does. +#[derive(Clone, Copy)] +enum Badge { + None, + Add, + Delete, + Close, + Convert, +} + +/// The pen nib, tip at `o` (top left), with a badge below right. +fn pen(ink: &mut Ink, o: Pos2, badge: Badge) { + ink.poly(at(o, &[(0.0, 0.0), (4.0, 12.0), (8.0, 16.0), (16.0, 8.0), (12.0, 4.0)]), INK); + // The nib's slit and breather hole. + ink.add(Shape::line_segment([o + vec2(1.0, 1.0), o + vec2(5.5, 5.5)], Stroke::new(1.0, HALO))); + ink.add(Shape::circle_filled(o + vec2(6.5, 6.5), 1.4, HALO)); + ink.line(o + vec2(10.0, 14.0), o + vec2(14.0, 18.0)); + let b = o + vec2(16.0, 14.0); + match badge { + Badge::None => {} + Badge::Add => { + ink.line(b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)); + ink.line(b + vec2(3.0, 0.0), b + vec2(3.0, 6.0)); + } + Badge::Delete => ink.line(b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)), + Badge::Close => { + ink.halo(Shape::circle_stroke(b + vec2(3.0, 3.0), 3.0, Stroke::new(3.0, HALO))); + ink.add(Shape::circle_stroke(b + vec2(3.0, 3.0), 3.0, Stroke::new(1.2, INK))); + } + Badge::Convert => { + ink.line(b + vec2(0.0, 6.0), b + vec2(3.0, 0.0)); + ink.line(b + vec2(3.0, 0.0), b + vec2(6.0, 6.0)); + } + } +} + +/// A curved double arrow over the hotspot: an arc bowing up, an arrowhead at each end pointing on +/// along the arc. +fn rotate(ink: &mut Ink, o: Pos2) { + const R: f32 = 8.0; + let (a0, a1) = (std::f32::consts::PI * 0.12, std::f32::consts::PI * 0.88); + let on = |a: f32| o + vec2(a.cos() * R, -a.sin() * R); + let pts: Vec = (0..=12).map(|i| on(a0 + (a1 - a0) * i as f32 / 12.0)).collect(); + ink.halo(Shape::line(pts.clone(), Stroke::new(3.2, HALO))); + ink.add(Shape::line(pts, Stroke::new(1.3, INK))); + for (a, away) in [(a0, -1.0f32), (a1, 1.0)] { + let t = vec2(-a.sin(), -a.cos()) * away; + let n = vec2(-t.y, t.x); + let (end, tip) = (on(a) - t * 1.5, on(a) + t * 4.5); + ink.poly(vec![tip, end + n * 1.8, end - n * 1.8], INK); + } +} + +/// The shapes of cursor `c` with its hotspot at `p`, in points; `None` for system cursors. +fn glyph(c: Cursor, p: Pos2) -> Option> { + let mut ink = Ink::default(); + match c { + Cursor::Arrow => arrow(&mut ink, p, false), + Cursor::ArrowHollow => arrow(&mut ink, p, true), + Cursor::Pen => pen(&mut ink, p, Badge::None), + Cursor::PenAdd => pen(&mut ink, p, Badge::Add), + Cursor::PenDelete => pen(&mut ink, p, Badge::Delete), + Cursor::PenClose => pen(&mut ink, p, Badge::Close), + Cursor::PenConvert => pen(&mut ink, p, Badge::Convert), + Cursor::Rotate => rotate(&mut ink, p), + _ => return None, + } + Some(ink.into_shapes()) +} + +/// The cursor bitmaps handed to the OS, by (cursor, pixels per point). Each glyph is rasterized +/// once; handing the same `Arc` every frame lets the integration keep its OS cursor. +#[derive(Default)] +pub struct Images(Vec<(Cursor, f32, CustomCursorImage)>); + +impl Images { + /// Cursor `c` for `ppp` physical pixels per point; `None` for cursors without a glyph. + pub fn get(&mut self, c: Cursor, ppp: f32) -> Option { + if let Some((.., image)) = self.0.iter().find(|(k, s, _)| *k == c && *s == ppp) { + return Some(image.clone()); + } + let image = rasterize(c, ppp)?; + // A few glyphs at the scales of the displays the window visited. + if self.0.len() >= 128 { + self.0.clear(); + } + self.0.push((c, ppp, image.clone())); + Some(image) + } +} + +/// The largest side of a cursor bitmap, in pixels (the glyphs span under 30 points). +const MAX_SIDE: f32 = 256.0; + +/// Cursor `c` as a straight-alpha RGBA bitmap at `ppp` pixels per point, tessellated +/// (anti-aliased) as egui would paint it, the hotspot on a pixel corner. +pub(crate) fn rasterize(c: Cursor, ppp: f32) -> Option { + if !(ppp.is_finite() && ppp > 0.0) { + return None; + } + let shapes = glyph(c, Pos2::ZERO)?; + let mut tessellator = Tessellator::new(ppp, TessellationOptions::default(), [1, 1], vec![]); + let mut mesh = Mesh::default(); + for shape in shapes { + tessellator.tessellate_shape(shape, &mut mesh); + } + let bounds = mesh.calc_bounds(); + // The hotspot must lie inside the bitmap. + let min = (bounds.min.to_vec2() * ppp).floor().min(Vec2::ZERO); + let max = (bounds.max.to_vec2() * ppp).ceil().max(Vec2::splat(1.0)); + let size = max - min; + if !(size.x <= MAX_SIDE && size.y <= MAX_SIDE) { + return None; + } + let (w, h) = (size.x as usize, size.y as usize); + let mut px = vec![[0.0; 4]; w * h]; + let corner = |v: &Vertex| (v.pos.to_vec2() * ppp - min, v.color); + for &[i, j, k] in mesh.indices.as_chunks::<3>().0 { + if let (Some(a), Some(b), Some(c)) = (mesh.vertices.get(i as usize), mesh.vertices.get(j as usize), mesh.vertices.get(k as usize)) { + fill_triangle(&mut px, w, [corner(a), corner(b), corner(c)]); + } + } + let rgba: Vec = px + .iter() + .flat_map(|&[r, g, b, a]| { + let k = if a > 0.0 { 1.0 / a } else { 0.0 }; + [r * k, g * k, b * k, a].map(|x| (x * 255.0).round().clamp(0.0, 255.0) as u8) + }) + .collect(); + Some(CustomCursorImage { rgba: rgba.into(), size: [w as u16, h as u16], hotspot: [-min.x as u16, -min.y as u16] }) +} + +/// Blend triangle `t` (pixel positions and premultiplied colours, interpolated across it) over `px` +/// (premultiplied RGBA, `w` pixels a row) at the pixel centres it covers, as the GPU blends egui's +/// meshes. A centre on an edge goes to one of the two triangles sharing it, so seams aren't doubled. +fn fill_triangle(px: &mut [[f32; 4]], w: usize, t: [(Vec2, Color32); 3]) { + let edge = |a: Vec2, b: Vec2, p: Vec2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); + let [a, mut b, mut c] = t; + let mut area = edge(a.0, b.0, c.0); + if area < 0.0 { + std::mem::swap(&mut b, &mut c); + area = -area; + } + if area.is_nan() || area <= 1e-6 || w == 0 { + return; + } + // Of the two triangles sharing an edge (walking it in opposite directions), one owns it. + let owns = |u: Vec2, v: Vec2| v.y > u.y || (v.y == u.y && v.x > u.x); + let h = px.len() / w; + let lo = a.0.min(b.0).min(c.0).max(Vec2::ZERO); + let hi = a.0.max(b.0).max(c.0).min(vec2(w as f32, h as f32)); + for y in lo.y.floor() as usize..hi.y.ceil() as usize { + for x in lo.x.floor() as usize..hi.x.ceil() as usize { + let p = pos2(x as f32 + 0.5, y as f32 + 0.5).to_vec2(); + let weights = [(b.0, c.0), (c.0, a.0), (a.0, b.0)].map(|(u, v)| (edge(u, v, p), owns(u, v))); + if !weights.iter().all(|&(e, own)| e > 0.0 || (e == 0.0 && own)) { + continue; + } + let Some(dst) = px.get_mut(y * w + x) else { continue }; + let [wa, wb, wc] = weights.map(|(e, _)| e / area); + let src: [f32; 4] = std::array::from_fn(|i| { + let channel = |col: Color32| f32::from(col.to_array()[i]); + (wa * channel(a.1) + wb * channel(b.1) + wc * channel(c.1)) / 255.0 + }); + let keep = 1.0 - src[3]; + *dst = std::array::from_fn(|i| src[i] + dst[i] * keep); + } + } +} + +/// The system cursor for `c`: shown where the window has no glyph for it, or no bitmap cursors. +pub fn system(c: Cursor) -> egui::CursorIcon { + use egui::CursorIcon as C; + match c { + Cursor::Arrow | Cursor::ArrowHollow => C::Default, + Cursor::Move => C::Move, + Cursor::Crosshair => C::Crosshair, + Cursor::ResizeH => C::ResizeHorizontal, + Cursor::ResizeV => C::ResizeVertical, + Cursor::ResizeNwSe => C::ResizeNwSe, + Cursor::ResizeNeSw => C::ResizeNeSw, + Cursor::Rotate => C::Alias, + Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenConvert => C::Crosshair, + Cursor::PathBracket => C::ResizeColumn, + Cursor::Text => C::Text, + Cursor::Hand => C::Grab, + Cursor::HandGrab => C::Grabbing, + Cursor::ZoomIn => C::ZoomIn, + Cursor::ZoomOut => C::ZoomOut, + Cursor::Eyedropper => C::Crosshair, + Cursor::LoadedText | Cursor::LoadedGraphic => C::Copy, + Cursor::ThreadLink => C::Alias, + Cursor::Unthread => C::NoDrop, + Cursor::NotAllowed => C::NotAllowed, + } +} + +/// Show cursor `c` with the pointer at `hover` (screen points): set the OS cursor bitmap or paint +/// the glyph per [`MODE`], and return the cursor icon to set. +pub fn show(ctx: &egui::Context, images: &mut Images, c: Cursor, hover: Pos2) -> egui::CursorIcon { + match MODE { + Mode::Os => { + // The glyphs keep the system cursors' size whatever the interface scale. + let ppp = ctx.pixels_per_point() / ctx.zoom_factor().max(0.01); + ctx.set_cursor_image(images.get(c, ppp)); + system(c) + } + Mode::Painted => { + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor")); + // Divide by the zoom factor so the glyph keeps its size in physical pixels. + let scale = 1.0 / ctx.zoom_factor().max(0.01); + let painter = ctx.layer_painter(layer); + match glyph(c, Pos2::ZERO) { + Some(shapes) => { + let place = egui::emath::TSTransform::new(hover.to_vec2(), scale); + painter.extend(shapes.into_iter().map(|mut s| { + s.transform(place); + s + })); + egui::CursorIcon::None + } + None => system(c), + } + } + Mode::System => system(c), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every cursor with a glyph. + const GLYPHS: [Cursor; 8] = + [Cursor::Arrow, Cursor::ArrowHollow, Cursor::Pen, Cursor::PenAdd, Cursor::PenDelete, Cursor::PenClose, Cursor::PenConvert, Cursor::Rotate]; + + fn alpha(img: &CustomCursorImage, x: usize, y: usize) -> u8 { + img.rgba.get((y * img.size[0] as usize + x) * 4 + 3).copied().unwrap_or(0) + } + + #[test] + fn every_glyph_rasterizes_with_its_hotspot_inside() { + for c in GLYPHS { + for ppp in [1.0, 2.0] { + let img = rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} has no bitmap")); + let [w, h] = img.size.map(usize::from); + assert_eq!(img.rgba.len(), w * h * 4, "{c:?}"); + let [hx, hy] = img.hotspot.map(usize::from); + assert!(hx < w && hy < h, "{c:?} hotspot {:?} outside {:?}", img.hotspot, img.size); + let inked = (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).filter(|&(x, y)| alpha(&img, x, y) > 128).count(); + assert!(inked > 20, "{c:?} at {ppp}× is nearly empty ({inked} pixels)"); + } + } + for c in [Cursor::Text, Cursor::Hand, Cursor::ZoomIn, Cursor::Crosshair] { + assert!(rasterize(c, 1.0).is_none(), "{c:?} stays a system cursor"); + } + assert!(rasterize(Cursor::Pen, f32::NAN).is_none() && rasterize(Cursor::Pen, 0.0).is_none()); + } + + #[test] + fn the_selection_arrows_differ_in_fill() { + // A pixel inside the arrow's body, below the tip: ink for Selection, halo for Direct. + let centre = |c| { + let img = rasterize(c, 2.0).unwrap_or_else(|| panic!("{c:?}")); + let [hx, hy] = img.hotspot.map(usize::from); + let i = ((hy + 16) * img.size[0] as usize + hx + 4) * 4; + img.rgba.get(i..i + 4).map(|p| p.to_vec()).unwrap_or_default() + }; + assert_eq!(centre(Cursor::Arrow), vec![0, 0, 0, 255], "Selection is filled"); + assert_eq!(centre(Cursor::ArrowHollow), vec![255, 255, 255, 255], "Direct Selection is hollow"); + } + + #[test] + fn images_are_cached_by_cursor_and_scale() { + let mut images = Images::default(); + let a = images.get(Cursor::Pen, 2.0).unwrap_or_else(|| panic!("pen")); + let b = images.get(Cursor::Pen, 2.0).unwrap_or_else(|| panic!("pen")); + assert!(std::sync::Arc::ptr_eq(&a.rgba, &b.rgba), "the same bitmap every frame"); + assert_ne!(images.get(Cursor::Pen, 1.0).map(|i| i.size), Some(a.size)); + assert!(images.get(Cursor::Text, 2.0).is_none()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1db2f591d..3a3121f6f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -11,6 +11,7 @@ pub mod canvas; pub mod chrome; pub mod control; pub mod credits; +pub mod cursors; pub mod dialogs; pub mod dock; pub mod i18n; @@ -542,6 +543,8 @@ pub struct DesignApp { pub(crate) quit_without_asking: bool, /// No document is discarded until every unsaved document has an up-to-date approval. quitting: Option, + /// The tool cursor bitmaps handed to the OS. + pub cursors: cursors::Images, } struct QuitState { @@ -594,6 +597,7 @@ impl DesignApp { quit_requested: false, quit_without_asking: false, quitting: None, + cursors: cursors::Images::default(), } } From cbba1fe7e1cc1e148e0e862090938f704b86e0ab Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 18:15:14 +0200 Subject: [PATCH 066/128] Tools panel as InDesign's: Gradient Feather a tool of its own; two-column layout; View Options; Apply Color and Apply Gradient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Nielsen's "The Adobe InDesign Toolbox" (InDesign 2020) walks the Tools panel in one and two columns. Compared with it: - Gradient Swatch and Gradient Feather are separate tools (one flyout here before). - In two columns InDesign lays out Content Collector and Content Placer, Apply Color / Apply Gradient / Apply None and Normal / Preview (with Bleed, Slug, Presentation in its flyout) side by side (SPLIT_IN_TWO_COLUMNS). One column keeps one well with a flyout, which now names what is in front (Apply Color, Apply Gradient or Apply None). - The utility button is View Options: Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid and Hidden Characters, checked when shown, with their shortcuts (17:40). It was Text Frame Options (Object › Text Frame Options…, ⌘B, still opens it). - Apply Color (,) applies the colour applied last ([Black] at first) and Apply Gradient (.) the gradient applied last, else the document's first, else white to black (InDesign's default), to the fill or stroke in front. The flyout had Apply Color as [Black] and no Apply Gradient. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/tools/src/catalog.rs | 13 +- crates/tools/src/lib.rs | 2 +- crates/ui-egui/src/i18n.rs | 6 + crates/ui-egui/src/i18n/ar.rs | 5 + crates/ui-egui/src/i18n/it.rs | 5 + crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 5 + crates/ui-egui/src/icons.rs | 13 ++ crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/menus.rs | 9 + crates/ui-egui/src/panels/mod.rs | 14 ++ crates/ui-egui/src/toolbar.rs | 283 +++++++++++++++++++++++++++---- 14 files changed, 336 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..0c39b7ce4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Tools panel follows InDesign's: Gradient Swatch and Gradient Feather are tools of their own; in two columns Content Collector and Placer, Apply Color / Gradient / None and Normal / Preview each get a button; View Options holds Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid and Hidden Characters; Apply Color (,) and Apply Gradient (.) reuse the last colour and gradient. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs index be36947cf..054a2ca45 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -16,6 +16,9 @@ const fn t(id: &'static str, label: &'static str, shortcut: Option<&'static str> /// Tools-panel groups in order (the first tool of each group shows by default). A `None` group /// marks a separator. +/// +/// In two columns InDesign lays some groups out tool by tool (Content Collector and Content +/// Placer side by side): see [`SPLIT_IN_TWO_COLUMNS`]. pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ &[t("selection", "Selection Tool", Some("V"), "tool-selection")], &[t("directSelection", "Direct Selection Tool", Some("A"), "tool-direct")], @@ -62,10 +65,8 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ t("scale", "Scale Tool", Some("S"), "tool-scale"), t("shear", "Shear Tool", Some("O"), "tool-shear"), ], - &[ - t("gradientSwatch", "Gradient Swatch Tool", Some("G"), "tool-gradient"), - t("gradientFeather", "Gradient Feather Tool", Some("Shift+G"), "tool-gradient-feather"), - ], + &[t("gradientSwatch", "Gradient Swatch Tool", Some("G"), "tool-gradient")], + &[t("gradientFeather", "Gradient Feather Tool", Some("Shift+G"), "tool-gradient-feather")], &[], &[t("note", "Note Tool", None, "tool-note")], &[ @@ -77,6 +78,10 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ &[t("zoom", "Zoom Tool", Some("Z"), "tool-zoom")], ]; +/// Groups (by their first tool) whose tools get a button each when the Tools panel has two +/// columns, as InDesign's does. +pub const SPLIT_IN_TWO_COLUMNS: &[&str] = &["contentCollector"]; + pub fn tool_info(id: &str) -> Option { TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.id == id).copied() } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 8dbc262ba..5e1e199c8 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -29,7 +29,7 @@ use designcraft_geom::{Affine, BezPath, Point, Rect, Unit, Vec2}; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub use catalog::{TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info}; +pub use catalog::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info}; pub use layout::CanvasLayout; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6d6503a00..73f8844c7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Apply Color (,)", ["Farbe anwenden (,)", "Appliquer la couleur (,)", "Aplicar color (,)", "カラーを適用 (,)", "应用颜色 (,)"]), + ("Apply Gradient (.)", ["Verlauf anwenden (.)", "Appliquer le dégradé (.)", "Aplicar degradado (.)", "グラデーションを適用 (.)", "应用渐变 (.)"]), + ("View Options", ["Ansichtsoptionen", "Options d’affichage", "Opciones de visualización", "表示オプション", "视图选项"]), + ("Frame Edges", ["Rahmenkanten", "Bords du bloc", "Bordes del marco", "フレーム枠", "框架边缘"]), + ("Rulers", ["Lineale", "Règles", "Reglas", "定規", "标尺"]), + ("Hidden Characters", ["Verborgene Zeichen", "Caractères masqués", "Caracteres ocultos", "制御文字", "隐藏字符"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 769511968..dc5648ad5 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Apply Color (,)", "تطبيق اللون (,)"), + ("Apply Gradient (.)", "تطبيق التدرج (.)"), + ("View Options", "خيارات العرض"), + ("Frame Edges", "حواف الإطار"), + ("Hidden Characters", "الأحرف المخفية"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 747511ae8..c01935da5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Apply Color (,)", "Applica colore (,)"), + ("Apply Gradient (.)", "Applica sfumatura (.)"), + ("View Options", "Opzioni di visualizzazione"), + ("Frame Edges", "Bordi cornice"), + ("Hidden Characters", "Caratteri nascosti"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 263d4226a..9a07eee07 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Apply Color (,)", "カラーを適用 (,)"), + ("Apply Gradient (.)", "グラデーションを適用 (.)"), + ("View Options", "表示オプション"), + ("Frame Edges", "フレーム枠"), + ("Hidden Characters", "制御文字"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 3f955f68f..c5a57befd 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Apply Color (,)", "Aplicar cor (,)"), + ("Apply Gradient (.)", "Aplicar degradê (.)"), + ("View Options", "Opções de visualização"), + ("Frame Edges", "Bordas do quadro"), + ("Hidden Characters", "Caracteres ocultos"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index efccfc166..369d30bf7 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Apply Color (,)", "Застосувати колір (,)"), + ("Apply Gradient (.)", "Застосувати градієнт (.)"), + ("View Options", "Параметри перегляду"), + ("Frame Edges", "Краї фреймів"), + ("Hidden Characters", "Приховані символи"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c93464374..f9e96526e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -454,6 +454,19 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(5.0, 10.0), (4.0, 10.0), (4.0, 17.0), (16.0, 17.0), (16.0, 10.0), (15.0, 10.0)]); } "screen-normal" => pen.rect(3.0, 4.0, 17.0, 16.0), + // View Options: a frame's dashed edges with a ruler's ticks above. + "view-options" => { + pen.w = 1.1; + for (x0, x1) in [(4.0, 6.5), (8.5, 11.5), (13.5, 16.0)] { + pen.line(&[(x0, 9.0), (x1, 9.0)]); + pen.line(&[(x0, 17.0), (x1, 17.0)]); + } + pen.line(&[(4.0, 9.0), (4.0, 17.0)]); + pen.line(&[(16.0, 9.0), (16.0, 17.0)]); + for x in [5.0, 8.0, 11.0, 14.0] { + pen.line(&[(x, 4.0), (x, 6.0)]); + } + } "screen-preview" => { pen.rect(3.0, 4.0, 17.0, 16.0); pen.frect(6.0, 7.0, 14.0, 13.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1db2f591d..f5ccb7d36 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -294,6 +294,12 @@ pub struct UiState { pub palette: Option, #[serde(skip)] pub flyout: Option, + /// The colour and gradient swatches applied last: what Apply Color (,) and Apply Gradient (.) + /// apply again. + #[serde(skip)] + pub last_color: Option, + #[serde(skip)] + pub last_gradient: Option, /// Fill/Stroke proxy (Tools and Color panels): the stroke is in front, so Apply None and the /// Color panel act on it (X toggles). #[serde(skip)] @@ -374,6 +380,8 @@ impl Default for UiState { dialog: None, palette: None, flyout: None, + last_color: None, + last_gradient: None, stroke_front: false, } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dc8b88bf2..ad37fad6b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -106,6 +106,13 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ), ("color.toggleFillStroke", "Toggle Fill and Stroke", Some("X"), "{} → {strokeFront} — which of fill and stroke the proxy has in front"), ("color.applyNone", "Apply None", Some("/"), "{} — [None] for the fill or stroke in front of the proxy"), + ("color.applyColor", "Apply Color", Some(","), "{} — the colour applied last ([Black] at first) to the fill or stroke in front of the proxy"), + ( + "color.applyGradient", + "Apply Gradient", + Some("."), + "{} — the gradient applied last (else the document's first, else white to black) to the fill or stroke in front of the proxy", + ), ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"), ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"), @@ -1151,6 +1158,8 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option crate::toolbar::default_fill_stroke(app), "color.swapFillStroke" => crate::toolbar::swap_fill_stroke(app), "color.toggleFillStroke" => crate::toolbar::toggle_fill_stroke(app), + "color.applyColor" => crate::toolbar::apply_color(app), + "color.applyGradient" => crate::toolbar::apply_gradient(app), "color.applyNone" => crate::toolbar::apply_none(app), "view.frameEdges" => flag(&mut app.ui.frame_edges), "view.overprintPreview" => { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index baf9a1251..fbfca247a 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -69,6 +69,7 @@ pub fn color_target(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { /// Apply a swatch (and tint) to the fill or stroke of what colour edits apply to. pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Option) -> Result { + remember_swatch(app, swatch); if colors_affect_text(app) { let (k, kt) = if stroke { ("stroke", "strokeTint") } else { ("fill", "fillTint") }; let mut attrs = json!({k: swatch}); @@ -84,6 +85,19 @@ pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Optio app.run(if stroke { "object.stroke" } else { "object.fill" }, p) } +/// Keep `swatch` as the colour or gradient Apply Color / Apply Gradient use next. +pub fn remember_swatch(app: &mut DesignApp, swatch: &str) { + let Some(st) = app.session.active() else { return }; + if swatch == designcraft_color::swatch::NONE { + return; + } + if designcraft_color::swatch::resolve_gradient(&st.doc.swatches, swatch).is_some() { + app.ui.last_gradient = Some(swatch.to_string()); + } else if st.doc.resolve_color(swatch, 1.0).is_some() { + app.ui.last_color = Some(swatch.to_string()); + } +} + /// Fill (or stroke) tint of the first selected item (1 = 100%). pub fn sel_tint(app: &DesignApp, stroke: bool) -> f32 { let Some(st) = app.session.active() else { return 1.0 }; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6e7d98487..7e6ca1cd2 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1,7 +1,7 @@ //! The Tools panel (left): single or double column, tool groups with flyouts, fill/stroke proxy, //! screen mode buttons. -use designcraft_tools::TOOL_GROUPS; +use designcraft_tools::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS}; use egui::{Color32, Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; @@ -45,13 +45,17 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(2.0); let current = app.session.tool_id(); let mut chosen: Option<&'static str> = None; - let mut row_items = Vec::new(); + // A slot is a group (its current tool shown, the others in its flyout) or, in two + // columns, one tool of a group InDesign lays out tool by tool. + let mut row_items: Vec)>> = Vec::new(); for (gi, g) in TOOL_GROUPS.iter().enumerate() { if g.is_empty() { row_items.push(None); - continue; + } else if cols == 2 && SPLIT_IN_TWO_COLUMNS.contains(&g[0].id) { + row_items.extend((0..g.len()).map(|k| Some((gi, Some(k))))); + } else { + row_items.push(Some((gi, None))); } - row_items.push(Some(gi)); } let mut i = 0; while i < row_items.len() { @@ -70,8 +74,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(8.0); ui.spacing_mut().item_spacing.x = 4.0; for _ in 0..cols { - let Some(Some(gi)) = row_items.get(i).copied() else { break }; - let g = TOOL_GROUPS[gi]; + let Some(Some((gi, pinned))) = row_items.get(i).copied() else { break }; + let group = TOOL_GROUPS[gi]; + let g = match pinned { + Some(k) => &group[k..=k], + None => group, + }; let shown = g.iter().find(|tl| tl.id == current).unwrap_or(&g[0]); let active = g.iter().any(|tl| tl.id == current); let tip = match shown.shortcut { @@ -173,7 +181,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { app.ui.flyout = None; } ui.add_space(8.0); - fill_stroke_proxy(app, ui, width); + fill_stroke_proxy(app, ui, width, cols); }); if cols == 1 { let h = scroll.content_size.y; @@ -192,7 +200,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// The area under the tools (InDesign 2026 §3 group 5): mini Default Fill/Stroke and Swap, the /// overlapping Fill/Stroke proxy, Formatting affects container / text, the Apply None well, a /// utility button and the Screen Mode well. -fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { +fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: usize) { let t = Tokens::get(ui.ctx()); let target = crate::panels::color_target(app); let doc = app.session.active().map(|d| d.doc.clone()); @@ -320,42 +328,147 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { } } ui.add_space(5.0); - // Apply None well (flyout: Apply Color / Gradient / None). + // Apply Color / Apply Gradient / Apply None: three buttons in two columns, as InDesign lays + // them out; one well with the kind in front (and a flyout of the three) in one column. + let front_sw = if stroke_front { &stroke_sw } else { &fill_sw }; + let front_kind = match doc.as_ref() { + Some(_) if front_sw == designcraft_color::swatch::NONE => 2, + Some(d) if designcraft_color::swatch::resolve_gradient(&d.swatches, front_sw).is_some() => 1, + _ => 0, + }; + let applies: [(&str, &str); 3] = + [("Apply Color (,)", "color.applyColor"), ("Apply Gradient (.)", "color.applyGradient"), ("Apply None (/)", "color.applyNone")]; + // Apply Color shows the colour it applies. + let last_color = app.ui.last_color.as_deref().and_then(|sw| doc.as_ref().and_then(|d| crate::widgets::swatch_colors(d, sw, 1.0).0)); + let paint_kind = |p: &egui::Painter, r: egui::Rect, k: usize| { + let chip = egui::Rect::from_center_size(r.center(), vec2(13.0, 13.0)); + match k { + 0 => { + p.rect_filled(chip, 0.0, last_color.unwrap_or(Color32::BLACK)); + } + 1 => crate::widgets::paint_chip(p, chip, None, Some((Color32::WHITE, Color32::BLACK))), + _ => crate::widgets::paint_chip(p, chip, None, None), + } + p.rect_stroke(chip, 0.0, Stroke::new(0.5, t.border), StrokeKind::Outside); + }; let (row, _) = ui.allocate_exact_size(vec2(width, 21.0), Sense::hover()); - let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); - let resp = ui.interact(well, ui.id().with("proxy_apply"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Apply None (/)")); - }); - ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); - let chip = egui::Rect::from_center_size(well.center(), vec2(14.0, 14.0)); - crate::widgets::paint_chip(ui.painter(), chip, None, None); - flyout_triangle(ui.painter(), well, t.icon); - if resp.clicked() { - let _ = app.run("color.applyNone", json!({})); - } - egui::Popup::context_menu(&resp).show(|ui| { - for (label, sw) in [("Apply Color", "[Black]"), ("Apply None", "[None]")] { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = crate::panels::apply_swatch(app, stroke_front, sw, None); - ui.close(); + if cols == 2 { + let pitch = 19.0; + for (k, (tip, id)) in applies.into_iter().enumerate() { + let c = egui::pos2(row.min.x + width / 2.0 + (k as f32 - 1.0) * pitch, row.center().y); + let r = egui::Rect::from_center_size(c, vec2(18.0, 18.0)); + let resp = ui.interact(r, ui.id().with(("proxy_apply", k)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, tip))); + if k == front_kind { + ui.painter().rect(r, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); + } + paint_kind(ui.painter(), r, k); + if resp + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); + }) + .clicked() + { + let _ = app.run(id, json!({})); } } - }); + } else { + let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); + let (tip, id) = applies[front_kind]; + let resp = ui.interact(well, ui.id().with("proxy_apply"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, tip))); + let resp = resp.on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); + }); + ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); + paint_kind(ui.painter(), well, front_kind); + flyout_triangle(ui.painter(), well, t.icon); + if resp.clicked() { + let _ = app.run(id, json!({})); + } + egui::Popup::context_menu(&resp).show(|ui| { + for (tip, id) in applies { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, tip))).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + }); + } ui.add_space(6.0); separator(ui, &t); - // Utility (frame options). + // View Options (InDesign's: Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid, Hidden + // Characters, each checked when shown). ui.vertical_centered(|ui| { - if tool_button(ui, "frame-options", false, crate::i18n::tr(&app.ui.language, "Text Frame Options")).clicked() { - app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); - } + let resp = tool_button(ui, "view-options", false, crate::i18n::tr(&app.ui.language, "View Options")); + flyout_triangle(ui.painter(), resp.rect.shrink2(vec2(2.0, 2.0)), t.icon); + let open = resp.clicked(); + let popup = egui::Popup::menu(&resp).open_memory(open.then_some(egui::SetOpenCommand::Toggle)); + popup.show(|ui| { + for (k, id) in + ["view.frameEdges", "view.rulers", "-", "view.guides", "view.smartGuides", "view.baselineGrid", "-", "view.hiddenCharacters"] + .into_iter() + .enumerate() + { + if id == "-" { + ui.separator(); + continue; + } + let on = crate::menus::checked(app, id, &Value::Null).unwrap_or(false); + let label = VIEW_OPTIONS[k]; + let sc = crate::menus::ui_label(id).and_then(|c| c.1).unwrap_or(""); + let text = format!("{} {}", if on { "✓" } else { " " }, crate::i18n::tr(&app.ui.language, label)); + if ui.add(egui::Button::new(crate::rtl::widget(ui, &text)).shortcut_text(sc)).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + }); }); separator(ui, &t); - // Screen Mode well with flyout. + // Screen Mode: Normal and Preview (with Bleed, Slug and Presentation) side by side in two + // columns, as InDesign's; one well with the five in its flyout in one column. let (row, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); + let mode = app.ui.screen_mode; + let modes = [("Preview", "preview"), ("Bleed", "bleed"), ("Slug", "slug"), ("Presentation", "presentation")]; + if cols == 2 { + let normal = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0 - 15.0, row.center().y), vec2(28.0, 21.0)); + let other = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0 + 15.0, row.center().y), vec2(28.0, 21.0)); + let is_normal = mode == crate::ScreenMode::Normal; + for (r, on, icon) in [(normal, is_normal, "screen-normal"), (other, !is_normal, "screen-preview")] { + if on { + ui.painter().rect(r, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); + } + icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), vec2(15.0, 15.0)), icon, if on { t.text_strong } else { t.icon }); + } + flyout_triangle(ui.painter(), other, t.icon); + let tr = |s: &str| crate::i18n::tr(&app.ui.language, s).to_string(); + let (normal_tip, other_tip) = (tr("Normal"), format!("{} (W)", tr("Preview"))); + let resp = ui.interact(normal, ui.id().with("screen_normal"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &normal_tip)); + if resp.on_hover_text(&normal_tip).clicked() { + let _ = app.run("view.screenMode", json!({"mode": "normal"})); + } + let resp = ui.interact(other, ui.id().with("screen_mode"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &other_tip)); + let resp = resp.on_hover_text(&other_tip); + if resp.clicked() { + let _ = app.run("view.screenMode", json!({"mode": "preview"})); + } + egui::Popup::context_menu(&resp).show(|ui| { + for (label, m) in modes { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + let _ = app.run("view.screenMode", json!({"mode": m})); + ui.close(); + } + } + }); + return; + } let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); let resp = ui.interact(well, ui.id().with("screen_mode"), Sense::click()); ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); - let preview = app.ui.screen_mode == crate::ScreenMode::Preview; + let preview = mode == crate::ScreenMode::Preview; icons::paint( ui.painter(), egui::Rect::from_center_size(well.center(), vec2(15.0, 15.0)), @@ -370,7 +483,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let _ = app.run("view.togglePreview", json!({})); } egui::Popup::context_menu(&resp).show(|ui| { - for (label, mode) in [("Normal", "normal"), ("Preview", "preview"), ("Bleed", "bleed"), ("Slug", "slug"), ("Presentation", "presentation")] { + for (label, mode) in std::iter::once(("Normal", "normal")).chain(modes) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { let _ = app.run("view.screenMode", json!({"mode": mode})); ui.close(); @@ -379,6 +492,9 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }); } +/// The View Options flyout's labels, by position (separators keep theirs empty). +const VIEW_OPTIONS: [&str; 8] = ["Frame Edges", "Rulers", "", "Guides", "Smart Guides", "Baseline Grid", "", "Hidden Characters"]; + /// The Object › Transform command a double-clicked tool opens (the Selection tool opens Move). fn transform_dialog_of(tool: &str) -> Option<&'static str> { match tool { @@ -444,6 +560,44 @@ pub(crate) fn toggle_fill_stroke(app: &mut DesignApp) -> Result { Ok(json!({"strokeFront": app.ui.stroke_front})) } +/// Apply Color (,): the colour applied last ([Black] at first) to the fill or stroke in front. +pub(crate) fn apply_color(app: &mut DesignApp) -> Result { + let sw = app.ui.last_color.clone().filter(|s| app.session.active().is_some_and(|d| d.doc.swatch(s).is_some())); + crate::panels::apply_swatch(app, app.ui.stroke_front, sw.as_deref().unwrap_or("[Black]"), None) +} + +/// Apply Gradient (.): the gradient applied last, else the document's first gradient swatch, else +/// a new white-to-black gradient (InDesign's default), on the fill or stroke in front. +pub(crate) fn apply_gradient(app: &mut DesignApp) -> Result { + let st = app.session.active().ok_or("no document")?; + let known = app + .ui + .last_gradient + .clone() + .filter(|s| designcraft_color::swatch::resolve_gradient(&st.doc.swatches, s).is_some()) + .or_else(|| st.doc.swatches.iter().find(|w| matches!(w.value, designcraft_color::SwatchValue::Gradient { .. })).map(|w| w.name.clone())); + let stroke = app.ui.stroke_front; + if let Some(sw) = known { + return crate::panels::apply_swatch(app, stroke, &sw, None); + } + if crate::panels::colors_affect_text(app) { + return Err("no gradient to apply yet: make one in the Gradient panel".into()); + } + let fill = crate::panels::color_target(app).map(|t| t.0); + let stops = json!([{"location": 0, "color": "#ffffff"}, {"location": 100, "color": "#000000"}]); + let r = app.run("object.gradient", json!({"stops": stops}))?; + let sw = r["swatch"].as_str().unwrap_or_default().to_string(); + app.ui.last_gradient = Some(sw.clone()); + if stroke { + // The gradient was made on the fill: it goes to the stroke, the fill gets its swatch back. + crate::panels::apply_swatch(app, true, &sw, None)?; + if let Some(f) = fill { + app.run("object.fill", json!({"swatch": f}))?; + } + } + Ok(r) +} + /// Apply None (/): to the fill or the stroke, whichever is in front of the proxy. pub(crate) fn apply_none(app: &mut DesignApp) -> Result { crate::panels::apply_swatch(app, app.ui.stroke_front, "[None]", None) @@ -550,6 +704,69 @@ mod tests { wheel(&mut h, panel.center(), vec2(0.0, -2000.0)); assert_eq!((rect(&h, LAST_TOOL), rect(&h, FILL)), before); } + #[test] + fn the_tools_panel_lays_out_like_indesigns() { + // Chris Nielsen's "The Adobe InDesign Toolbox" (InDesign 2020): Gradient Swatch and Gradient + // Feather are tools of their own; in two columns Content Collector and Placer, Apply + // Color / Gradient / None and Normal / Preview each get a button. + for double in [false, true] { + let h = test_window::open(app(double), vec2(1400.0, 900.0)); + let _ = rect(&h, "Gradient Swatch Tool (G)"); + let _ = rect(&h, "Gradient Feather Tool (Shift+G)"); + let _ = rect(&h, "View Options"); + let split = ["Content Placer Tool (B)", "Apply Color (,)", "Apply Gradient (.)", "Normal"]; + for label in split { + assert_eq!(h.query_by_label(label).is_some(), double, "double column {double}: {label}"); + } + let _ = rect(&h, "Content Collector Tool (B)"); + // One column: the Apply well names what is in front (no fill: Apply None). + let _ = rect(&h, "Apply None (/)"); + if double { + let (a, b) = (rect(&h, "Content Collector Tool (B)"), rect(&h, "Content Placer Tool (B)")); + assert!((a.center().y - b.center().y).abs() < 1.0 && a.center().x < b.center().x, "side by side: {a:?} {b:?}"); + let (n, p) = (rect(&h, "Normal"), rect(&h, "Preview (W)")); + assert!((n.center().y - p.center().y).abs() < 1.0, "Normal and Preview side by side"); + } + } + } + + #[test] + fn apply_color_and_gradient_reuse_the_last_ones() { + use serde_json::json; + let mut app = app(true); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200], "content": "unassigned"})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let fill = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + let it = st.doc.item(st.selection.items[0]).unwrap(); + (it.fill.swatch.clone(), it.stroke.swatch.clone()) + }; + // Apply Color: [Black] at first, then the colour last applied. + app.run("color.applyColor", json!({})).unwrap(); + assert_eq!(fill(&app).0, "[Black]"); + crate::panels::apply_swatch(&mut app, false, "C=100 M=0 Y=0 K=0", None).unwrap(); + app.run("color.applyNone", json!({})).unwrap(); + assert_eq!(fill(&app).0, "[None]"); + app.run("color.applyColor", json!({})).unwrap(); + assert_eq!(fill(&app).0, "C=100 M=0 Y=0 K=0"); + // Apply Gradient: white to black when there's none yet, then that one again. + app.run("color.applyGradient", json!({})).unwrap(); + let g = fill(&app).0; + let doc = &app.session.active().unwrap().doc; + let grad = designcraft_color::swatch::resolve_gradient(&doc.swatches, &g).expect("a gradient"); + assert_eq!((grad.stops[0].color.to_rgb(), grad.stops[1].color.to_rgb()), ([1.0; 3], [0.0; 3])); + app.run("color.applyColor", json!({})).unwrap(); + // With the stroke in front, it goes to the stroke; the fill keeps its colour. + app.ui.stroke_front = true; + app.run("color.applyGradient", json!({})).unwrap(); + assert_eq!(fill(&app), ("C=100 M=0 Y=0 K=0".to_string(), g)); + // The shortcuts: , and . + for (id, sc) in [("color.applyColor", ","), ("color.applyGradient", ".")] { + assert_eq!(crate::menus::ui_label(id).and_then(|c| c.1), Some(sc)); + } + } + /// The Fill/Stroke proxy's keys run on the selection and stay out of text being typed. #[test] fn fill_stroke_proxy_keys() { From a65a83b765ba9db4ed2919c64d89d45621f82f95 Mon Sep 17 00:00:00 2001 From: tydude001 Date: Wed, 14 Oct 2026 11:36:19 -0500 Subject: [PATCH 067/128] packaging: RPM requires library sonames, so openSUSE can install it DesignCraft's RPM requires Fedora package names: `libxkbcommon`, `(vulkan-loader or libglvnd-egl)`. openSUSE has no `libxkbcommon`, `vulkan-loader` or `libglvnd-egl` package, so zypper refuses it with `nothing provides 'libxkbcommon'` (exit 4, which the ArtCraft Launcher shows as "error 4"; storytold/craft-launcher#341). Co-Authored-By: Claude Opus 5.5 --- packaging/linux/nfpm.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index e2ce72b10..7e146d1a0 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -42,9 +42,16 @@ overrides: - mesa-vulkan-drivers - xdg-desktop-portal rpm: + # Library sonames rather than package names, so the same requirements resolve on Fedora/RHEL + # and openSUSE (which has no libxkbcommon, vulkan-loader or libglvnd-egl package). The glibc + # floor names libm because the binaries' GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's + # glibc 2.34 provides libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses. depends: - - libxkbcommon - - (vulkan-loader or libglvnd-egl) + - libc.so.6()(64bit) + - libm.so.6(GLIBC_2.35)(64bit) + - libgcc_s.so.1()(64bit) + - libxkbcommon.so.0()(64bit) + - (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit)) recommends: - libwayland-client - libX11 From 20d30e226605b7bd515127edb81b37653e4f8f7a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 18:46:57 +0200 Subject: [PATCH 068/128] Images: indexed-colour TIFFs decode through their palette (fixes #927) --- crates/images/src/lib.rs | 152 +++++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 7ce48c0a3..d59f4741c 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -150,6 +150,9 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { /// Pixel dimensions of a raster. pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { + if let Some(p) = palette_tiff(bytes) { + return Some((p.width, p.height)); + } if is_psd(bytes) && bytes.len() >= 26 { // Header: signature, version, 6 reserved, channels, height, width (big-endian). let be = |i: usize| u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]); @@ -163,6 +166,10 @@ pub fn decode_rgba(bytes: &[u8]) -> Option { if is_psd(bytes) { return psd::decode(bytes); } + // `image` rejects indexed-colour TIFFs (an old GIF logo saved from Photoshop). + if let Some(img) = decode_palette_tiff(bytes) { + return Some(img); + } // An EPS is drawn as its proxy (preview, simple PostScript or placeholder); the document // keeps the EPS itself. if is_eps(bytes) { @@ -228,6 +235,98 @@ fn tiff_decoder(bytes: &[u8]) -> Option, +} + +/// The header of a palette TIFF with 1-, 2-, 4- or 8-bit indices and a full ColorMap, which +/// neither `image` nor `tiff` decodes. `None` for anything else. +fn palette_tiff(bytes: &[u8]) -> Option { + use tiff::tags::Tag; + let mut d = tiff_decoder(bytes)?; + if d.find_tag_unsigned::(Tag::PhotometricInterpretation).ok()?? != 3 { + return None; + } + if d.find_tag_unsigned::(Tag::SamplesPerPixel).ok()?.unwrap_or(1) != 1 { + return None; + } + let bits = d.find_tag_unsigned::(Tag::BitsPerSample).ok()?.unwrap_or(1); + if !matches!(bits, 1 | 2 | 4 | 8) { + return None; + } + // 2^bits reds, then greens, then blues; 16-bit entries whose high byte is the 8-bit value. + let map = d.find_tag_unsigned_vec::(Tag::ColorMap).ok()??; + let n = 1usize << bits; + let (r, gb) = map.split_at_checked(n)?; + let (g, b) = gb.split_at_checked(n)?; + if b.len() != n { + return None; + } + let colours = r.iter().zip(g).zip(b).map(|((&r, &g), &b)| [r, g, b].map(|v| (v >> 8) as u8)).collect(); + let (width, height) = d.dimensions().ok()?; + Some(PaletteTiff { width, height, bits, colours }) +} + +/// A palette TIFF as RGBA, each index looked up in the ColorMap. +fn decode_palette_tiff(bytes: &[u8]) -> Option { + let p = palette_tiff(bytes)?; + // Read as gray, `tiff` hands back the indices themselves, whatever the compression or layout. + let gray = with_photometric(bytes, 1)?; + let tiff::decoder::DecodingResult::U8(px) = tiff_decoder(&gray)?.read_image().ok()? else { return None }; + let (w, h) = (usize::try_from(p.width).ok()?, usize::try_from(p.height).ok()?); + let bits = usize::from(p.bits); + // Indices below 8 bits are packed high bits first, each row starting on a byte. + let row = w.checked_mul(bits)?.div_ceil(8); + if row == 0 || px.len() < row.checked_mul(h)? { + return None; + } + let per_byte = 8 / bits; + let mask = ((1u16 << bits) - 1) as u8; + let mut out = Vec::new(); + out.try_reserve_exact(w.checked_mul(h)?.checked_mul(4)?).ok()?; + for line in px.chunks_exact(row).take(h) { + for x in 0..w { + let shift = 8 - bits * (x % per_byte + 1); + let i = (line.get(x / per_byte)? >> shift) & mask; + let [r, g, b] = *p.colours.get(usize::from(i))?; + out.extend([r, g, b, 255]); + } + } + image::RgbaImage::from_raw(p.width, p.height, out) +} + +/// A copy of a TIFF with its first image's PhotometricInterpretation (tag 262, one SHORT) set +/// to `value`. Classic and BigTIFF, either byte order. +fn with_photometric(bytes: &[u8], value: u16) -> Option> { + let le = bytes.starts_with(b"II"); + let int = |at: usize, len: usize| -> Option { + let b = bytes.get(at..at.checked_add(len)?)?; + let v = |acc: u64, &x: &u8| acc << 8 | u64::from(x); + usize::try_from(if le { b.iter().rev().fold(0, v) } else { b.iter().fold(0, v) }).ok() + }; + // Classic: 4-byte offsets and value counts, 2-byte entry count, 12-byte entries. BigTIFF: + // 8, 8, 8 and 20. + let (ifd, word, count_len, entry_len) = if int(2, 2)? == 43 { (int(8, 8)?, 8, 8, 20) } else { (int(4, 4)?, 4, 2, 12) }; + let count = int(ifd, count_len)?; + for k in 0..count { + let at = ifd.checked_add(count_len)?.checked_add(k.checked_mul(entry_len)?)?; + // Tag, type (3 = SHORT), value count, then the value itself. + if int(at, 2)? == 262 && int(at + 2, 2)? == 3 && int(at + 4, word)? == 1 { + let mut out = bytes.to_vec(); + let v = if le { value.to_le_bytes() } else { value.to_be_bytes() }; + let value_at = at + 4 + word; + out.get_mut(value_at..value_at + 2)?.copy_from_slice(&v); + return Some(out); + } + } + None +} + /// A TIFF whose pixels are CMYK (PhotometricInterpretation Separated, four inks). pub fn is_cmyk_tiff(bytes: &[u8]) -> bool { tiff_decoder(bytes).and_then(|mut d| d.colortype().ok()).is_some_and(|c| matches!(c, tiff::ColorType::CMYK(_) | tiff::ColorType::CMYKA(_))) @@ -435,6 +534,59 @@ mod tests { assert_eq!(decode_cmyk_tiff(&b[..b.len() / 2]), None, "truncated"); } + /// Indexed colour (PhotometricInterpretation 3), which the `tiff` encoder has no type for. + struct Palette; + impl tiff::encoder::colortype::ColorType for Palette { + type Inner = u8; + const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::RGBPalette; + const BITS_PER_SAMPLE: &'static [u16] = &[BITS]; + const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint]; + fn horizontal_predict(row: &[u8], result: &mut Vec) { + result.extend_from_slice(row); + } + } + + /// A palette TIFF as Photoshop and Pillow write it: 16-bit ColorMap entries (reds, then + /// greens, then blues), each 8-bit value times 256. + fn palette_tiff(w: u32, h: u32, px: &[u8], colours: &[[u8; 3]], lzw: bool) -> Vec { + let n = 1usize << BITS; + let mut map = vec![0u16; 3 * n]; + for (i, c) in colours.iter().enumerate() { + for (ch, &v) in c.iter().enumerate() { + map[ch * n + i] = u16::from(v) << 8; + } + } + let mut b = Vec::new(); + let mut enc = tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut b)).unwrap(); + if lzw { + enc = enc.with_compression(tiff::encoder::Compression::Lzw); + } + let mut img = enc.new_image::>(w, h).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ColorMap, &map[..]).unwrap(); + img.write_data(px).unwrap(); + b + } + + /// Indexed-colour TIFFs (an old GIF logo saved from Photoshop) are sized and drawn through + /// their palette, uncompressed or LZW, 8- or 4-bit. + #[test] + fn palette_tiff_decodes_through_its_colormap() { + let colours = [[0, 128, 255], [255, 0, 0], [10, 20, 30]]; + for lzw in [false, true] { + let b = palette_tiff::<8>(3, 1, &[2, 0, 1], &colours, lzw); + assert_eq!(pixel_size(&b), Some((3, 1)), "lzw {lzw}"); + let img = decode_rgba(&b).expect("decodes"); + let px: Vec<_> = img.pixels().map(|p| p.0).collect(); + assert_eq!(px, [[10, 20, 30, 255], [0, 128, 255, 255], [255, 0, 0, 255]], "lzw {lzw}"); + } + // Two indices per byte, high nibble first: 2, 1 | 0, 2 (the encoder counts samples, not + // bytes, so the strip carries two spare bytes the decoder doesn't read). + let b = palette_tiff::<4>(2, 2, &[0x21, 0x02, 0, 0], &colours, false); + assert_eq!(pixel_size(&b), Some((2, 2))); + let px: Vec<_> = decode_rgba(&b).expect("decodes").pixels().map(|p| p.0).collect(); + assert_eq!(px, [[10, 20, 30, 255], [255, 0, 0, 255], [0, 128, 255, 255], [10, 20, 30, 255]]); + } + #[test] fn bmp_decodes() { let mut bmp = Vec::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 06f59de94..0d5a61d54 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -55,7 +55,7 @@ score below). |---|---|---|---|---|---| | PDF / AI (PDF-compatible) | `.pdf`, `.ai` | ✓ page boxes, layers, pages | ✓ rendered with hayro, embedded as vector in PDF export, crop to page boxes | layer visibility depth; editable vectors not offered (#227) | 3% | | JPEG, PNG, GIF, BMP, WebP | | ✓ | ✓ | CMYK JPEG/TIFF keep inks in PDF (#35) | 2% | -| TIFF | `.tif` | ✓ incl. CMYK, alpha, paths | ✓ incl. CMYK | clipping paths from 8BIM resources unverified | 1% | +| TIFF | `.tif` | ✓ incl. CMYK, alpha, indexed colour, paths | ✓ incl. CMYK | clipping paths from 8BIM resources unverified | 1% | | PSD | `.psd` | ✓ layers, comps, alpha, paths | ~ composite + Object Layer Options | layer comps partial | 1% | | EPS | `.eps` | ✓ (PostScript) | ~ preview only (no interpreter) | | <1% | | SVG | `.svg` | ✓ | ✓ (usvg) | | <1% | From 23ac4ce3f15c5e4ee2b7f474c9375e0c20da6224 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 12:51:12 -0400 Subject: [PATCH 069/128] fix: integrate batched community PRs (#845's Find scope test uses the Page tool's double-click from #862) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b40a0504a..bf096ce31 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4429,8 +4429,9 @@ mod tests { let off = app.session.layout().xf(designcraft_engine::doc::SpreadRef::Doc(0)).translation(); let x = off.x + before.doc.spreads[0].pages[0].x + 200.0; let y = off.y + 200.0; + // The Page tool opens Page Size on a double-click (a click selects the page). let events = if tool == "page" { - json!([{"kind": "up", "x": x, "y": y}]) + json!([{"kind": "doubleclick", "x": x, "y": y}]) } else { json!([{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]) }; From 19bacaadd7f8e9c1fd532466a405da2a08322b8e Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:41:51 +0400 Subject: [PATCH 070/128] i18n: Georgian (ka) interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds i18n/ka.rs (1,734 entries: every key of the shared, Arabic, Brazilian Portuguese, Japanese, Italian and Ukrainian tables, plus labels only the code and command table use), registered as ქართული in LANGUAGES and the Interface Language menu. Count captions use the category-first form Ukrainian already uses (გვერდი: 5), since Georgian nouns stay singular after a numeral. Tests: every Georgian key resolves, placeholders and ellipses are preserved, no Mtavruli, every menu title, item and submenu is translated, and switching to ka renders. Docs: localization-parity.md and ROADMAP. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- ROADMAP.md | 4 +- crates/ui-egui/src/i18n.rs | 41 +- crates/ui-egui/src/i18n/ka.rs | 1777 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 28 +- docs/localization-parity.md | 8 +- 5 files changed, 1848 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/i18n/ka.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0dbb60372..12a7da03f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,7 +99,7 @@ Interface strings translated, of 1,724 measured. Detail: [docs/localization-pari | Korean | ko | none | 0% | | Vietnamese | vi | none | 0% | -Also shipped: Italian (85%), Ukrainian (83%). +Also shipped: Italian (85%), Ukrainian (83%), Georgian (100%). ## Upcoming @@ -150,6 +150,8 @@ Newest first. `text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No readiness percentage change. +- **2026-10-14** · Georgian interface (`ka`, Edit ▸ Interface Language ▸ ქართული): every catalog key + translated, count captions as `noun: n`. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 04ee153c0..8c14d97c4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -6,6 +6,7 @@ use std::{collections::HashMap, sync::OnceLock}; mod ar; mod it; mod ja; +mod ka; mod pt_br; mod uk; @@ -21,6 +22,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("pt-br", "Português (Brasil)"), ("it", "Italiano"), ("uk", "Українська"), + ("ka", "ქართული"), ]; /// English → [German, French, Spanish, Japanese, Simplified Chinese]. @@ -3014,6 +3016,7 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { static UKRAINIAN: OnceLock> = OnceLock::new(); static PORTUGUESE_BR: OnceLock> = OnceLock::new(); static ITALIAN: OnceLock> = OnceLock::new(); + static GEORGIAN: OnceLock> = OnceLock::new(); if lang == "ar" { return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } @@ -3030,6 +3033,9 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { if lang == "it" { return ITALIAN.get_or_init(|| it::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } + if lang == "ka" { + return GEORGIAN.get_or_init(|| ka::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); + } if lang == "uk" { return UKRAINIAN.get_or_init(|| uk::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } @@ -3050,10 +3056,11 @@ pub(crate) fn ukrainian_catalog() -> Vec<&'static str> { uk::TABLE.iter().map(|(_, text)| *text).chain(uk::CONTEXT.iter().map(|(_, _, text)| *text)).collect() } -/// Count captions in Ukrainian put the category before the value so every count uses -/// a grammatical form; other languages keep the existing value-first caption. +/// Count captions in Ukrainian and Georgian put the category before the value so every count +/// uses one grammatical form (Georgian nouns stay singular after numerals: `გვერდი: 5`); other +/// languages keep the existing value-first caption. pub fn count_label(lang: &str, noun: &str, count: usize) -> String { - if lang == "uk" { format!("{}: {count}", tr(lang, noun)) } else { format!("{count} {}", tr(lang, noun)) } + if matches!(lang, "uk" | "ka") { format!("{}: {count}", tr(lang, noun)) } else { format!("{count} {}", tr(lang, noun)) } } /// Localize reserved built-in style names only; user-defined names are document data. @@ -3099,8 +3106,11 @@ mod tests { for (key, expected) in uk::TABLE { assert_eq!(tr("uk", key), *expected, "uk: {key}"); } + for (key, expected) in ka::TABLE { + assert_eq!(tr("ka", key), *expected, "ka: {key}"); + } let unknown = String::from("A user-defined untranslated label"); - for lang in ["uk", "ar", "pt-br", "it", "zh", "ja", "de", "fr", "es", "", "unknown"] { + for lang in ["uk", "ar", "pt-br", "it", "ka", "zh", "ja", "de", "fr", "es", "", "unknown"] { assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str())); } } @@ -3185,6 +3195,29 @@ mod tests { assert_eq!(workspace_name("uk", "My workspace"), "My workspace"); } + #[test] + fn georgian_catalog_preserves_placeholders_and_counts() { + let catalog: HashMap<_, _> = ka::TABLE.iter().copied().collect(); + assert_eq!(catalog.len(), ka::TABLE.len(), "duplicate Georgian keys"); + let placeholders = |s: &str| { + s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::>() + }; + for (key, translation) in ka::TABLE { + assert!(!translation.trim().is_empty(), "empty {key}"); + assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}"); + assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}"); + // Georgian has no capitals in running text: Mtavruli never appears in the interface. + assert!(!translation.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "Mtavruli: {key}"); + } + assert_eq!(tr("ka", "File"), "ფაილი"); + assert_eq!(tr("ka", "Save As…"), "შენახვა როგორც…"); + assert_eq!(tr("ka", "Unknown label"), "Unknown label"); + assert!(!is_rtl("ka")); + for n in [1, 2, 5, 21] { + assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}")); + } + } + #[test] fn ukrainian_counts_use_category_captions_for_every_number() { for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] { diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs new file mode 100644 index 000000000..10fb84d3e --- /dev/null +++ b/crates/ui-egui/src/i18n/ka.rs @@ -0,0 +1,1777 @@ +//! Original Georgian interface translations, written from the English source following the shared +//! Georgian glossary of the ArtCraft apps (commands as verbal nouns: შენახვა, გახსნა; no capitals, +//! since Georgian has no case; counts as "noun: n"). Every key of the shared, Arabic, Brazilian +//! Portuguese, Japanese, Italian and Ukrainian tables has a row here, plus a few labels that only +//! the code or the command table use. +pub(super) const TABLE: &[(&str, &str)] = &[ + ("File", "ფაილი"), + ("Edit", "რედაქტირება"), + ("Layout", "განლაგება"), + ("Type", "ტექსტი"), + ("Object", "ობიექტი"), + ("Table", "ცხრილი"), + ("View", "ხედი"), + ("Window", "ფანჯარა"), + ("Help", "დახმარება"), + ("New", "ახალი"), + ("New Document…", "ახალი დოკუმენტი…"), + ("Open…", "გახსნა…"), + ("Close", "დახურვა"), + ("Save", "შენახვა"), + ("Save As…", "შენახვა როგორც…"), + ("Save a Copy…", "ასლის შენახვა…"), + ("Revert", "წინა ვერსიაზე დაბრუნება"), + ("Place…", "განთავსება…"), + ("Export PDF…", "PDF ექსპორტი…"), + ("Print…", "დაბეჭდვა…"), + ("Document Setup…", "დოკუმენტის პარამეტრები…"), + ("Package…", "შეფუთვა…"), + ("Undo", "დაბრუნება"), + ("Redo", "ხელახლა შესრულება"), + ("Cut", "ამოჭრა"), + ("Copy", "კოპირება"), + ("Paste", "ჩასმა"), + ("Reverse Path", "ტრაექტორიის შებრუნება"), + ("Set Anchor", "საყრდენი წერტილის დაყენება"), + ("Clear", "გასუფთავება"), + ("Duplicate", "დუბლირება"), + ("Select All", "ყველაფრის მონიშვნა"), + ("Deselect All", "ყველა მონიშვნის მოხსნა"), + ("Find/Change…", "ძებნა/ჩანაცვლება…"), + ("Check Spelling…", "მართლწერის შემოწმება…"), + ("Preferences…", "პარამეტრები…"), + ("Keyboard Shortcuts…", "კლავიატურის მალსახმობები…"), + ("Interface Language", "ინტერფეისის ენა"), + ("Pages", "გვერდები"), + ("Margins and Columns…", "ველები და სვეტები…"), + ("Create Guides…", "დამხმარე ხაზების შექმნა…"), + ("Numbering & Section Options…", "დანომვრისა და სექციის პარამეტრები…"), + ("Story Direction", "ტექსტის მიმართულება"), + ("Horizontal", "ჰორიზონტალური"), + ("Vertical", "ვერტიკალური"), + ("Tate-Chu-Yoko", "ტატე-ჩუ-იოკო"), + ("Ruby…", "რუბი…"), + ("Kenten", "კენტენი"), + ("Warichu", "ვარიჩუ"), + ("Font", "შრიფტი"), + ("Size", "ზომა"), + ("Character", "სიმბოლო"), + ("Paragraph", "აბზაცი"), + ("Tabs", "ტაბულაცია"), + ("Glyphs", "გლიფები"), + ("Story", "ტექსტური ნაკადი"), + ("Character Styles", "სიმბოლოს სტილები"), + ("Paragraph Styles", "აბზაცის სტილები"), + ("Create Outlines", "კონტურებად გარდაქმნა"), + ("Show Hidden Characters", "დამალული სიმბოლოების ჩვენება"), + ("Transform", "გარდაქმნა"), + ("Arrange", "დალაგება"), + ("Group", "დაჯგუფება"), + ("Ungroup", "დაჯგუფების მოხსნა"), + ("Lock", "ჩაკეტვა"), + ("Hide", "დამალვა"), + ("Effects", "ეფექტები"), + ("Fitting", "ზომაზე მორგება"), + ("Text Frame Options…", "ტექსტური ჩარჩოს პარამეტრები…"), + ("Insert Table…", "ცხრილის ჩასმა…"), + ("Merge Cells", "უჯრების შერწყმა"), + ("Unmerge Cells", "უჯრების შერწყმის გაუქმება"), + ("Insert", "ჩასმა"), + ("Delete", "წაშლა"), + ("Zoom In", "გადიდება"), + ("Zoom Out", "დაპატარავება"), + ("Fit Page in Window", "გვერდის ფანჯარაში ჩატევა"), + ("Fit Spread in Window", "გაშლის ფანჯარაში ჩატევა"), + ("Actual Size", "რეალური ზომა"), + ("Screen Mode", "ეკრანის რეჟიმი"), + ("Grids & Guides", "ბადე და დამხმარე ხაზები"), + ("Show Rulers", "სახაზავების ჩვენება"), + ("Snap to Guides", "დამხმარე ხაზებზე მიბმა"), + ("Snap to Document Grid", "დოკუმენტის ბადეზე მიბმა"), + ("Smart Guides", "ჭკვიანი დამხმარე ხაზები"), + ("Align to Object Edges", "ობიექტების კიდეებზე სწორება"), + ("Align to Object Centers", "ობიექტების ცენტრებზე სწორება"), + ("Smart Dimensions", "ჭკვიანი ზომები"), + ("Smart Spacing", "ჭკვიანი დაშორებები"), + ("Snap to Zone", "მიბმის ზონა"), + ("Properties", "თვისებები"), + ("Layers", "შრეები"), + ("Links", "ბმულები"), + ("Swatches", "ფერის ნიმუშები"), + ("Color", "ფერი"), + ("Stroke", "კონტური"), + ("Gradient", "გრადიენტი"), + ("Align", "სწორება"), + ("Pathfinder", "ტრაექტორიების ოპერაციები"), + ("Info", "ინფორმაცია"), + ("Library", "ბიბლიოთეკა"), + ("Book", "წიგნი"), + ("Index", "ინდექსი"), + ("Hyperlinks", "ჰიპერბმულები"), + ("Bookmarks", "სანიშნეები"), + ("Articles", "სტატიები"), + ("Tags", "ტეგები"), + ("Media", "მედია"), + ("Object States", "ობიექტის მდგომარეობები"), + ("Buttons and Forms", "ღილაკები და ფორმები"), + ("Liquid Layout", "მოქნილი განლაგება"), + ("Workspace", "სამუშაო სივრცე"), + ("Split Window", "ფანჯრის გაყოფა"), + ("New Window", "ახალი ფანჯარა"), + ("Show All Menu Items", "მენიუს ყველა პუნქტის ჩვენება"), + ("Accept All", "ყველას მიღება"), + ("Add", "დამატება"), + ("Add Document…", "დოკუმენტის დამატება…"), + ("Add to Previous Spread", "წინა გაშლაზე დამატება"), + ("Align From:", "სწორება საიდან:"), + ("Align to baseline grid", "საბაზისო ხაზების ბადეზე სწორება"), + ("Alignment:", "სწორება:"), + ("All Spots to Process", "ყველა სპოტ-ფერის პროცესულად გარდაქმნა"), + ("Allow Spread to Shuffle", "გაშლის გვერდების გადანაწილების დაშვება"), + ("Alternating Row Fills", "სტრიქონების მონაცვლე შევსება"), + ("Appearance", "იერსახე"), + ("Apply Gradient", "გრადიენტის გამოყენება"), + ("Apply Parent", "საბაზო გვერდის გამოყენება"), + ("Apply Style:", "სტილის გამოყენება:"), + ("Apply to All Spreads", "ყველა გაშლაზე გამოყენება"), + ("Apply to Stroke", "კონტურზე გამოყენება"), + ("Art", "ნახატი"), + ("Based On:", "საფუძველი:"), + ("Bleed", "ჭრისპირა არე"), + ("Bleed Marks", "ჭრისპირა არის ნიშნები"), + ("Body Rows", "ძირითადი სტრიქონები"), + ("Bottom", "ქვემოთ"), + ("Break Link to Style", "სტილთან კავშირის გაწყვეტა"), + ("Bullets and Numbering", "ბურთულები და დანომვრა"), + ("Business Card", "სავიზიტო ბარათი"), + ("CMYK", "CMYK"), + ("Cancel", "გაუქმება"), + ("Can't Open the File", "ფაილის გახსნა ვერ მოხერხდა"), + ("Can't Place the File", "ფაილის განთავსება ვერ მოხერხდა"), + ("Center Content", "შიგთავსის ცენტრირება"), + ("Change All", "ყველას ჩანაცვლება"), + ("Change to:", "ჩანაცვლება:"), + ("Character Style:", "სიმბოლოს სტილი:"), + ("Checked by default", "ნაგულისხმევად მონიშნული"), + ("Choices", "ვარიანტები"), + ("Choose Image…", "გამოსახულების არჩევა…"), + ("Class:", "კლასი:"), + ("Color Picker", "ფერის ამრჩევი"), + ("Color Settings", "ფერის პარამეტრები"), + ("Color:", "ფერი:"), + ("Columns", "სვეტები"), + ("Composer:", "აწყობის რეჟიმი:"), + ("Copies:", "ასლები:"), + ("Count", "რაოდენობა"), + ("Create", "შექმნა"), + ("Create Table", "ცხრილის შექმნა"), + ("Create Table…", "ცხრილის შექმნა…"), + ("Crop", "ჩამოჭრა"), + ("Crop Marks", "ჭრის ნიშნები"), + ("Crop to:", "ჩამოჭრა:"), + ("Cursor Key:", "კურსორის კლავიშები:"), + ("Default", "ნაგულისხმევი"), + ("Default View:", "ნაგულისხმევი ხედი:"), + ("Delete Column", "სვეტის წაშლა"), + ("Delete Condition", "პირობის წაშლა"), + ("Delete Layer", "შრის წაშლა"), + ("Delete Page", "გვერდის წაშლა"), + ("Delete Row", "სტრიქონის წაშლა"), + ("Delete Swatch", "ფერის ნიმუშის წაშლა"), + ("Delete Unused Layers", "გამოუყენებელი შრეების წაშლა"), + ("Desired", "სასურველი"), + ("Dialog", "დიალოგი"), + ("Document", "დოკუმენტი"), + ("Document Setup", "დოკუმენტის პარამეტრები"), + ("Drop Shadow", "ჩრდილი"), + ("Duplicate Spread", "გაშლის დუბლირება"), + ("Email", "ელფოსტა"), + ("Embed", "ჩაშენება"), + ("Export PDF", "PDF ექსპორტი"), + ("Export XML…", "XML ექსპორტი…"), + ("Fast", "სწრაფი"), + ("Fill Frame Proportionally", "ჩარჩოს პროპორციულად შევსება"), + ("Find All", "ყველას ძებნა"), + ("Find Next", "შემდეგის ძებნა"), + ("Find what:", "რა მოიძებნოს:"), + ("Find/Change", "ძებნა/ჩანაცვლება"), + ("Find/Replace Font", "შრიფტის ძებნა/ჩანაცვლება"), + ("Fit Content Proportionally", "შიგთავსის პროპორციულად მორგება"), + ("Fit Content to Frame", "შიგთავსის ჩარჩოზე მორგება"), + ("Fitting:", "ზომაზე მორგება:"), + ("Flattener preset", "გაბრტყელების პრესეტი"), + ("Font Family:", "შრიფტის ოჯახი:"), + ("Font Size:", "შრიფტის ზომა:"), + ("Font Style:", "შრიფტის სტილი:"), + ("Footer Rows", "ქვედა კოლონტიტულის სტრიქონები"), + ("Footnote Options", "სქოლიოს პარამეტრები"), + ("Form Field", "ფორმის ველი"), + ("Format:", "ფორმატი:"), + ("Frame Fitting", "ჩარჩოს ზომაზე მორგება"), + ("Frame Fitting Options", "ჩარჩოს ზომაზე მორგების პარამეტრები"), + ("Generate QR Code", "QR კოდის შექმნა"), + ("Go To", "გადასვლა"), + ("Go to Page", "გვერდზე გადასვლა"), + ("Gutter", "შუალედი"), + ("Header Rows", "სათაურის სტრიქონები"), + ("Height", "სიმაღლე"), + ("Height:", "სიმაღლე:"), + ("High Quality", "მაღალი ხარისხი"), + ("Desktop Printing", "სამაგიდო ბეჭდვა"), + ("Horizontal Gridline Every:", "ჰორიზონტალური ბადის ხაზი ყოველ:"), + ("Horizontal Margins:", "ჰორიზონტალური ველები:"), + ("Hyphenate", "სიტყვების გადატანა"), + ("Import Options", "იმპორტის პარამეტრები"), + ("Import XML…", "XML იმპორტი…"), + ("Include document bleed", "დოკუმენტის ჭრისპირა არის ჩართვა"), + ("Increment Every:", "ბიჯი ყოველ:"), + ("Ink Manager", "საღებავების მართვა"), + ("Insert Column", "სვეტის ჩასმა"), + ("Insert Page After", "გვერდის ჩასმა შემდეგ"), + ("Insert Row", "სტრიქონის ჩასმა"), + ("Inset spacing", "შიდა დაშორება"), + ("Inside", "შიგნით"), + ("Intent:", "განზრახვა:"), + ("Keyboard Shortcuts", "კლავიატურის მალსახმობები"), + ("Kind:", "სახეობა:"), + ("Lab", "Lab"), + ("Leading:", "სტრიქონთაშორისი ინტერვალი:"), + ("Left Indent:", "მარცხენა შეწევა:"), + ("Link To:", "ბმული:"), + ("Load Profile…", "პროფილის ჩატვირთვა…"), + ("Load Swatches…", "ფერის ნიმუშების ჩატვირთვა…"), + ("Load…", "ჩატვირთვა…"), + ("Make Compound", "შედგენილის შექმნა"), + ("Margins", "ველები"), + ("Marks and Bleeds", "ნიშნები და ჭრისპირა არეები"), + ("Maximum", "მაქსიმუმი"), + ("Menu Customization", "მენიუს მორგება"), + ("Merge into Active Layer", "აქტიურ შრესთან შერწყმა"), + ("Min:", "მინ.:"), + ("Minimum", "მინიმუმი"), + ("Minimum Space Before First Footnote:", "მინიმალური დაშორება პირველ სქოლიომდე:"), + ("Misspelled word → Correction (one per line):", "შეცდომით დაწერილი სიტყვა → შესწორება (თითო სტრიქონზე):"), + ("Mobile", "მობილური"), + ("Move Selection Here", "მონიშნულის აქ გადატანა"), + ("Move to Color Group", "ფერების ჯგუფში გადატანა"), + ("Move to Group", "ჯგუფში გადატანა"), + ("Multiline", "მრავალსტრიქონიანი"), + ("Name", "სახელი"), + ("Name:", "სახელი:"), + ("New Bookmark for This Page", "ახალი სანიშნე ამ გვერდისთვის"), + ("New Book…", "ახალი წიგნი…"), + ("New Color Group", "ფერების ახალი ჯგუფი"), + ("New Color Group with Swatch", "ფერების ახალი ჯგუფი ნიმუშით"), + ("New Cross-Reference", "ახალი ჯვარედინი მიმართვა"), + ("New Document", "ახალი დოკუმენტი"), + ("New GREP Style", "ახალი GREP სტილი"), + ("New Library…", "ახალი ბიბლიოთეკა…"), + ("New Line Style", "ხაზის ახალი სტილი"), + ("New Nested Style", "ახალი ჩადგმული სტილი"), + ("New Workspace", "ახალი სამუშაო სივრცე"), + ("No spot inks", "სპოტ-საღებავები არ არის"), + ("None", "არცერთი"), + ("Number of Pages:", "გვერდების რაოდენობა:"), + ("Number of Sides:", "კუთხეების რაოდენობა:"), + ("Number of columns", "სვეტების რაოდენობა"), + ("Object Layer Options", "ობიექტის შრეების პარამეტრები"), + ("Offset:", "წანაცვლება:"), + ("On Release", "აშვებისას"), + ("On Screen:", "ეკრანზე:"), + ("OpenType", "OpenType"), + ("Orientation:", "ორიენტაცია:"), + ("Outside", "გარეთ"), + ("Page Information", "გვერდის ინფორმაცია"), + ("Page Range", "გვერდების დიაპაზონი"), + ("Page Range:", "გვერდების დიაპაზონი:"), + ("Pages:", "გვერდები:"), + ("Paragraph Composer", "აბზაცის მიხედვით აწყობა"), + ("Paragraph Style Options", "აბზაცის სტილის პარამეტრები"), + ("Paragraph Style:", "აბზაცის სტილი:"), + ("PDF/A-2b", "PDF/A-2b"), + ("PDF/X-4", "PDF/X-4"), + ("Place PDF", "PDF-ის განთავსება"), + ("Plain Text", "უბრალო ტექსტი"), + ("Polygon Settings", "მრავალკუთხედის პარამეტრები"), + ("Position", "პოზიცია"), + ("Position:", "პოზიცია:"), + ("Poster", "პოსტერი"), + ("Preferences", "პარამეტრები"), + ("Prefix:", "პრეფიქსი:"), + ("Preset", "პრესეტი"), + ("Preset:", "პრესეტი:"), + ("Commercial Printing", "კომერციული ბეჭდვა"), + ("Print", "ბეჭდვა"), + ("Printer:", "პრინტერი:"), + ("Printing / Exporting:", "ბეჭდვა / ექსპორტირება:"), + ("Quick Actions", "სწრაფი მოქმედებები"), + ("RGB", "RGB"), + ("Rectangle", "მართკუთხედი"), + ("Reject All", "ყველას უარყოფა"), + ("Relative To:", "შეფარდებით:"), + ("Release", "გათავისუფლება"), + ("Relink to Folder…", "საქაღალდეში ხელახლა დაკავშირება…"), + ("Relink…", "ხელახლა დაკავშირება…"), + ("Remove Conditions from Selection", "მონიშნულიდან პირობების მოხსნა"), + ("Repeat Header Rows", "სათაურის სტრიქონების გამეორება"), + ("Required", "სავალდებულო"), + ("Reset All to Defaults", "ყველაფრის ნაგულისხმევზე დაბრუნება"), + ("Restart Numbering Every:", "დანომვრის თავიდან დაწყება ყოველ:"), + ("Reverse", "შებრუნება"), + ("Save Swatches for Exchange…", "ფერის ნიმუშების შენახვა გასაცვლელად…"), + ("Save recovery data every:", "აღდგენის მონაცემების შენახვა ყოველ:"), + ("Search:", "ძიება:"), + ("Select an object.", "მონიშნეთ ობიექტი."), + ("Select text or a text frame.", "მონიშნეთ ტექსტი ან ტექსტური ჩარჩო."), + ("Separator:", "გამყოფი:"), + ("Show Prefix/Suffix in:", "პრეფიქსის/სუფიქსის ჩვენება:"), + ("Show Tag Markers", "ტეგის მარკერების ჩვენება"), + ("Single-line", "ერთსტრიქონიანი"), + ("Single-line Composer", "სტრიქონის მიხედვით აწყობა"), + ("Size:", "ზომა:"), + ("Screen and Email", "ეკრანი და ელფოსტა"), + ("Space Between Footnotes:", "დაშორება სქოლიოებს შორის:"), + ("Speed", "სიჩქარე"), + ("Standard", "სტანდარტული"), + ("Standard:", "სტანდარტი:"), + ("Star Inset:", "ვარსკვლავის ჩაღრმავება:"), + ("Start Page #:", "საწყისი გვერდის №:"), + ("Start at:", "დაწყება:"), + ("Start:", "დასაწყისი:"), + ("Style Name:", "სტილის სახელი:"), + ("Style:", "სტილი:"), + ("Stylistic Sets", "სტილისტური ნაკრებები"), + ("Subdivisions:", "ქვედანაყოფები:"), + ("Suffix:", "სუფიქსი:"), + ("Synchronize", "სინქრონიზაცია"), + ("Tag:", "ტეგი:"), + ("Tagged PDF", "ტეგირებული PDF"), + ("Text Frame", "ტექსტური ჩარჩო"), + ("Text Frame Options", "ტექსტური ჩარჩოს პარამეტრები"), + ("Text Message", "ტექსტური შეტყობინება"), + ("Text Style", "ტექსტის სტილი"), + ("Text Wrap", "ტექსტით გარშემოვლება"), + ("This PDF has no layers.", "ამ PDF-ს შრეები არ აქვს."), + ("To Text:", "ტექსტამდე:"), + ("Top", "ზემოთ"), + ("Track Changes in All Stories", "ცვლილებების ჩაწერა ყველა ტექსტურ ნაკადში"), + ("Trim", "საბოლოო ფორმატი"), + ("Type:", "ტიპი:"), + ("Typical", "ტიპური"), + ("UI Size:", "ინტერფეისის ზომა:"), + ("Ungroup Color Group", "ფერების ჯგუფის დაშლა"), + ("Unmerge", "შერწყმის გაუქმება"), + ("Update", "განახლება"), + ("Update Numbering", "დანომვრის განახლება"), + ("Use Global Light", "გლობალური განათების გამოყენება"), + ("User Dictionary", "მომხმარებლის ლექსიკონი"), + ("Vertical Gridline Every:", "ვერტიკალური ბადის ხაზი ყოველ:"), + ("Vertical Margins:", "ვერტიკალური ველები:"), + ("Vertical justification", "ვერტიკალური სწორება"), + ("View Threshold:", "ხილვადობის ზღვარი:"), + ("Web", "ვები"), + ("Web Hyperlink", "ვებბმული"), + ("Weight:", "სისქე:"), + ("Width", "სიგანე"), + ("Width:", "სიგანე:"), + ("Words, one per line:", "სიტყვები, თითო სტრიქონზე:"), + ("Multiple Objects", "რამდენიმე ობიექტი"), + ("Ellipse", "ელიფსი"), + ("Polygon", "მრავალკუთხედი"), + ("Characters", "სიმბოლოები"), + ("Image", "გამოსახულება"), + ("Line", "ხაზი"), + ("Path", "ტრაექტორია"), + ("No Selection", "მონიშნული არაფერია"), + ("Adjust Layout", "განლაგების მისადაგება"), + ("Edit Page", "გვერდის რედაქტირება"), + ("Import File", "ფაილის იმპორტი"), + ("Insert Page", "გვერდის ჩასმა"), + ("Hide Bar", "ზოლის დამალვა"), + ("Properties Panel", "თვისებების პანელი"), + ("New Workspace…", "ახალი სამუშაო სივრცე…"), + ("Float panel", "მცურავი პანელი"), + ("Dock panel", "პანელის მიმაგრება"), + ("Fit frame height to text", "ჩარჩოს სიმაღლის ტექსტზე მორგება"), + ("More Options", "მეტი პარამეტრი"), + ("Flip Horizontal", "ჰორიზონტალურად არეკვლა"), + ("Rotate 90° Clockwise", "90°-ით შემობრუნება საათის ისრის მიმართულებით"), + ("Flip Vertical", "ვერტიკალურად არეკვლა"), + ("Default Fill and Stroke (D)", "ნაგულისხმევი შევსება და კონტური (D)"), + ("Swap Fill and Stroke (Shift+X)", "შევსებისა და კონტურის გაცვლა (Shift+X)"), + ("Fill (X)", "შევსება (X)"), + ("Stroke (X)", "კონტური (X)"), + ("Apply None (/)", "არცერთი (/)"), + ("Default Fill and Stroke", "ნაგულისხმევი შევსება და კონტური"), + ("Swap Fill and Stroke", "შევსებისა და კონტურის გაცვლა"), + ("Toggle Fill and Stroke", "შევსებისა და კონტურის გადართვა"), + ("Reverse the gradient", "გრადიენტის შებრუნება"), + ("Style source", "სტილების წყარო"), + ("Open", "გახსნა"), + ("All Caps", "მთავრული"), + ("Underline", "ხაზგასმული"), + ("Strikethrough", "გადახაზული"), + ("Superscript", "ზედა ინდექსი"), + ("Pick a file in the folder holding the moved graphics", "აირჩიეთ ფაილი საქაღალდეში, სადაც გადატანილი გრაფიკაა"), + ("Show Favorites Only", "მხოლოდ რჩეულების ჩვენება"), + ("Select", "მონიშვნა"), + ("Hyperlink from the selected text or frames", "ჰიპერბმული მონიშნული ტექსტიდან ან ჩარჩოებიდან"), + ("Include when exporting", "ექსპორტისას ჩართვა"), + ("Add the selection", "მონიშნულის დამატება"), + ("Tag the selection", "მონიშნულისთვის ტეგის მინიჭება"), + ("Separated by commas", "მძიმით გამოყოფილი"), + ("Show in the text", "ტექსტში ჩვენება"), + ("Show / hide text with this condition", "ამ პირობის მქონე ტექსტის ჩვენება / დამალვა"), + ("Spreads of up to 10 pages", "გაშლები 10 გვერდამდე"), + ("for", "მომდევნო"), + ("About DesignCraft", "DesignCraft-ის შესახებ"), + ("Quit DesignCraft", "DesignCraft-იდან გასვლა"), + ("Import Warnings", "იმპორტის გაფრთხილებები"), + ("Accept All Changes", "ყველა ცვლილების მიღება"), + ("Activate Document", "დოკუმენტის გააქტიურება"), + ("Add Anchor Point", "საყრდენი წერტილის დამატება"), + ("Add Document", "დოკუმენტის დამატება"), + ("Add Hyphenation Exception", "გადატანის გამონაკლისის დამატება"), + ("Add Item", "ელემენტის დამატება"), + ("Add Page to Spread", "გვერდის გაშლაზე დამატება"), + ("Add to Swatches", "ფერის ნიმუშებში დამატება"), + ("Allow Selected Spread to Shuffle", "მონიშნული გაშლის გვერდების გადანაწილების დაშვება"), + ("Anchored Object Options…", "მიმაგრებული ობიექტის პარამეტრები…"), + ("Anchored Objects", "მიმაგრებული ობიექტები"), + ("Appearance of Black", "შავის იერსახე"), + ("Apply Attributes (Eyedropper)", "ატრიბუტების გამოყენება (პიპეტი)"), + ("Apply Cell Style", "უჯრის სტილის გამოყენება"), + ("Apply Color", "ფერის გამოყენება"), + ("Repeat", "გამეორება"), + ("Count:", "რაოდენობა:"), + ("Create as a grid", "ბადედ შექმნა"), + ("Rows:", "სტრიქონები:"), + ("Invalid value", "არასწორი მნიშვნელობა"), + ("Isolate Blending", "შერევის იზოლირება"), + ("Knockout Group", "ჩაკვეთილი ჯგუფი"), + ("Object:", "ობიექტი:"), + ("Group:", "ჯგუფი:"), + ("Sample Color (Eyedropper)", "ფერის აღება (პიპეტი)"), + ("Apply Condition", "პირობის გამოყენება"), + ("Apply Parent to Pages…", "საბაზო გვერდის გამოყენება გვერდებზე…"), + ("ArtCraft App Page…", "ArtCraft-ის აპლიკაციის გვერდი…"), + ("ArtCraft Website…", "ArtCraft-ის ვებსაიტი…"), + ("Attributes", "ატრიბუტები"), + ("Bevel and Emboss", "ფასკა და რელიეფი"), + ("Buttons", "ღილაკები"), + ("Cell Options", "უჯრის პარამეტრები"), + ("Cell Style Options…", "უჯრის სტილის პარამეტრები…"), + ("Change All (Object)", "ყველას ჩანაცვლება (ობიექტი)"), + ("Change Case", "რეგისტრის შეცვლა"), + ("Changes", "ცვლილებები"), + ("Character Formatting", "სიმბოლოს ფორმატირება"), + ("Clear Conveyor", "კონვეიერის გასუფთავება"), + ("Clear Transformations", "გარდაქმნების გასუფთავება"), + ("Clipping Path…", "ჩაჭრის ტრაექტორია…"), + ("Close Book", "წიგნის დახურვა"), + ("Close Library", "ბიბლიოთეკის დახურვა"), + ("Collect", "შეგროვება"), + ("Color Settings…", "ფერის პარამეტრები…"), + ("Community & Project Links", "საზოგადოება და პროექტის ბმულები"), + ("Compress images", "გამოსახულებების შეკუმშვა"), + ("Compression", "შეკუმშვა"), + ("Conditions", "პირობები"), + ("Container", "კონტეინერი"), + ("Content", "შიგთავსი"), + ("Contextual Task Bar", "კონტექსტური ამოცანების ზოლი"), + ("Control", "მართვა"), + ("Convert Cell to Graphic Cell", "უჯრის გრაფიკულ უჯრად გარდაქმნა"), + ("Convert Cell to Graphic Cell…", "უჯრის გრაფიკულ უჯრად გარდაქმნა…"), + ("Convert Cell to Text Cell", "უჯრის ტექსტურ უჯრად გარდაქმნა"), + ("Convert Color", "ფერის გარდაქმნა"), + ("Convert Direction Point", "მიმართულების წერტილის გარდაქმნა"), + ("Convert Note to Text", "შენიშვნის ტექსტად გარდაქმნა"), + ("Convert Selection to Multi-State Object", "მონიშნულის მრავალმდგომარეობიან ობიექტად გარდაქმნა"), + ("Convert Shape", "ფიგურის გარდაქმნა"), + ("Convert Table to Text", "ცხრილის ტექსტად გარდაქმნა"), + ("Convert Text to Table", "ტექსტის ცხრილად გარდაქმნა"), + ("Convert to Button", "ღილაკად გარდაქმნა"), + ("Convert to Form Field", "ფორმის ველად გარდაქმნა"), + ("Conveyor", "კონვეიერი"), + ("Copy Link(s) To…", "ბმული ფაილების კოპირება…"), + ("Corner Options…", "კუთხეების პარამეტრები…"), + ("Create Alternate Layout…", "ალტერნატიული განლაგების შექმნა…"), + ("Create Frame", "ჩარჩოს შექმნა"), + ("Create Merged Document…", "შერწყმული დოკუმენტის შექმნა…"), + ("Cross-Reference Formats", "ჯვარედინი მიმართვის ფორმატები"), + ("Cross-References", "ჯვარედინი მიმართვები"), + ("Data Merge Fields", "მონაცემთა შერწყმის ველები"), + ("Define Cross-Reference Format…", "ჯვარედინი მიმართვის ფორმატის განსაზღვრა…"), + ("Define Lists…", "სიების განსაზღვრა…"), + ("Define Text Variable…", "ტექსტური ცვლადის განსაზღვრა…"), + ("Delete All Guides on Spread", "გაშლის ყველა დამხმარე ხაზის წაშლა"), + ("Delete Anchor Point", "საყრდენი წერტილის წაშლა"), + ("Delete Item", "ელემენტის წაშლა"), + ("Delete Text Variable", "ტექსტური ცვლადის წაშლა"), + ("Delete Workspace…", "სამუშაო სივრცის წაშლა…"), + ("DesignCraft Website…", "DesignCraft-ის ვებსაიტი…"), + ("DesignCraft on GitHub…", "DesignCraft GitHub-ზე…"), + ("Detach All Objects from Parent", "ყველა ობიექტის საბაზო გვერდისგან მოწყვეტა"), + ("Directional Feather", "კიდეების მიმართული შერბილება"), + ("Distribute", "განაწილება"), + ("Dock Panel", "პანელის მიმაგრება"), + ("Document Endnote Options…", "დოკუმენტის ბოლო სქოლიოების პარამეტრები…"), + ("Document Footnote Options…", "დოკუმენტის სქოლიოების პარამეტრები…"), + ("Document Preferences", "დოკუმენტის პარამეტრები"), + ("Document Presets", "დოკუმენტის პრესეტები"), + ("Document…", "დოკუმენტი…"), + ("Dynamic Spelling", "მართლწერის შემოწმება აკრეფისას"), + ("Edit Parents", "საბაზო გვერდების რედაქტირება"), + ("Edit Story", "ტექსტური ნაკადის რედაქტირება"), + ("Edit in Story Editor", "ტექსტის რედაქტორში რედაქტირება"), + ("Endnotes", "დოკუმენტის სქოლიოები"), + ("Entire Pasteboard", "მთელი სამუშაო არე"), + ("Erase", "წაშლა"), + ("Export Book to PDF…", "წიგნის PDF ექსპორტი…"), + ("Export EPUB (Fixed Layout)…", "EPUB ექსპორტი (ფიქსირებული განლაგება)…"), + ("Export EPUB (Reflowable)…", "EPUB ექსპორტი (ადაპტური განლაგება)…"), + ("Export EPUB…", "EPUB ექსპორტი…"), + ("Export HTML…", "HTML ექსპორტი…"), + ("Export IDML…", "IDML ექსპორტი…"), + ("Export PDF (Interactive)…", "PDF ექსპორტი (ინტერაქტიური)…"), + ("Export Page as PNG…", "გვერდის PNG ფაილად ექსპორტირება…"), + ("Export Selection as Snippet", "მონიშნულის ფრაგმენტად ექსპორტი"), + ("Export Tagging", "ექსპორტის ტეგები"), + ("Export Text…", "ტექსტის ექსპორტი…"), + ("Extend Text Selection", "ტექსტის მონიშვნის გაფართოება"), + ("Fast Display", "სწრაფი ჩვენება"), + ("Find", "ძებნა"), + ("Find Object", "ობიექტის ძებნა"), + ("Find by Script Label", "სკრიპტის ჭდით ძებნა"), + ("Find/Replace Font…", "შრიფტის ძებნა/ჩანაცვლება…"), + ("First Object Above", "პირველი ობიექტი ზემოთ"), + ("Fit Selection in Window", "მონიშნულის ფანჯარაში ჩატევა"), + ("Flattener Preview", "გაბრტყელების წინასწარი ნახვა"), + ("Float Panel", "მცურავი პანელი"), + ("Fonts in Document", "დოკუმენტის შრიფტები"), + ("Footnotes", "სქოლიოები"), + ("Form Fields", "ფორმის ველები"), + ("Frame Fitting Options…", "ჩარჩოს ზომაზე მორგების პარამეტრები…"), + ("Generate Index…", "ინდექსის შექმნა…"), + ("Generate QR Code…", "QR კოდის შექმნა…"), + ("Generate Static Caption", "სტატიკური წარწერის შექმნა"), + ("Get Story", "ტექსტური ნაკადის მიღება"), + ("Get Table", "ცხრილის მიღება"), + ("Global Light…", "გლობალური განათება…"), + ("Go To Link", "ბმულზე გადასვლა"), + ("Go To Source", "წყაროზე გადასვლა"), + ("Go to Footnote Reference", "სქოლიოს მითითებაზე გადასვლა"), + ("Go to Page…", "გვერდზე გადასვლა…"), + ("Gradient Feather", "კიდეების გრადიენტული შერბილება"), + ("Gridify", "ბადედ განლაგება"), + ("Group Transparency", "ჯგუფის გამჭვირვალობა"), + ("Guides", "დამხმარე ხაზები"), + ("Hide Menu Item", "მენიუს პუნქტის დამალვა"), + ("Hide/Lock Others", "დანარჩენების დამალვა/ჩაკეტვა"), + ("High Quality Display", "მაღალი ხარისხის ჩვენება"), + ("History", "ისტორია"), + ("Hyphenation Exceptions", "გადატანის გამონაკლისები"), + ("Import Options: Styles", "იმპორტის პარამეტრები: სტილები"), + ("Index Page References", "ინდექსის გვერდის მითითებები"), + ("Inner Glow", "შიდა ნათება"), + ("Inner Shadow", "შიდა ჩრდილი"), + ("Insert Anchored Object…", "მიმაგრებული ობიექტის ჩასმა…"), + ("Insert Cross-Reference…", "ჯვარედინი მიმართვის ჩასმა…"), + ("Insert Endnote", "დოკუმენტის სქოლიოს ჩასმა"), + ("Insert Footnote", "სქოლიოს ჩასმა"), + ("Insert Math Expression…", "მათემატიკური გამოსახულების ჩასმა…"), + ("Insert Pages…", "გვერდების ჩასმა…"), + ("Insert Variable", "ცვლადის ჩასმა"), + ("Inspect Document", "დოკუმენტის შემოწმება"), + ("Interface Color Theme", "ინტერფეისის ფერთა თემა"), + ("Join the ArtCraft Discord…", "შემოუერთდით ArtCraft-ის Discord-ს…"), + ("Last Object Below", "ბოლო ობიექტი ქვემოთ"), + ("Library Contents", "ბიბლიოთეკის შიგთავსი"), + ("Library Items", "ბიბლიოთეკის ელემენტები"), + ("Linked Stories", "დაკავშირებული ტექსტური ნაკადები"), + ("Liquid Page Rule", "გვერდის მოქნილი წესი"), + ("List Documents", "დოკუმენტების სია"), + ("List Styles", "სტილების სია"), + ("List Tools", "ხელსაწყოების სია"), + ("Load Place Cursor", "განთავსების კურსორის ჩატვირთვა"), + ("Make Compound Path", "შედგენილი ტრაექტორიის შექმნა"), + ("Map Styles to Tags", "სტილების ტეგებთან შესაბამისობა"), + ("Math Expression SVG", "მათემატიკური გამოსახულება SVG"), + ("Media Options", "მედიის პარამეტრები"), + ("Menus…", "მენიუები…"), + ("Merge Layers", "შრეების შერწყმა"), + ("Move Caret", "კურსორის გადაადგილება"), + ("Move Gap", "ღრეჩოს გადაადგილება"), + ("Move Row", "სტრიქონის გადატანა"), + ("New Book", "ახალი წიგნი"), + ("New Cell Style…", "უჯრის ახალი სტილი…"), + ("New Color Swatch…", "ფერის ახალი ნიმუში…"), + ("New Condition…", "ახალი პირობა…"), + ("New Guide", "ახალი დამხმარე ხაზი"), + ("New Hyperlink Destination…", "ჰიპერბმულის ახალი დანიშნულება…"), + ("New Hyperlink…", "ახალი ჰიპერბმული…"), + ("New Library", "ახალი ბიბლიოთეკა"), + ("New Page Reference…", "გვერდის ახალი მითითება…"), + ("New Paragraph Style…", "აბზაცის ახალი სტილი…"), + ("New Stroke Style…", "კონტურის ახალი სტილი…"), + ("New Table Style…", "ცხრილის ახალი სტილი…"), + ("Next Cell", "შემდეგი უჯრა"), + ("Next Document", "შემდეგი დოკუმენტი"), + ("Next Object Above", "შემდეგი ობიექტი ზემოთ"), + ("Next Object Below", "შემდეგი ობიექტი ქვემოთ"), + ("Next Object in Group", "ჯგუფის შემდეგი ობიექტი"), + ("Notes", "შენიშვნები"), + ("Object Export Options", "ობიექტის ექსპორტის პარამეტრები"), + ("Object Export Options…", "ობიექტის ექსპორტის პარამეტრები…"), + ("Object Layer Options…", "ობიექტის შრეების პარამეტრები…"), + ("Object Layers", "ობიექტის შრეები"), + ("Object Liquid Settings", "ობიექტის მოქნილი პარამეტრები"), + ("Open Book", "წიგნის გახსნა"), + ("Open Bytes", "ბაიტების გახსნა"), + ("Open IDML", "IDML-ის გახსნა"), + ("Open Library", "ბიბლიოთეკის გახსნა"), + ("Outer Glow", "გარე ნათება"), + ("Overprint Preview", "ზედდაბეჭდვის წინასწარი ნახვა"), + ("Override All Parent Page Items", "საბაზო გვერდის ყველა ელემენტის გადაფარვა"), + ("Page Size", "გვერდის ზომა"), + ("Page Transition", "გვერდის გადასვლა"), + ("Page Transitions", "გვერდების გადასვლები"), + ("Paragraph Formatting", "აბზაცის ფორმატირება"), + ("Paragraph Style Options…", "აბზაცის სტილის პარამეტრები…"), + ("Paste Into", "შიგნით ჩასმა"), + ("Paste without Formatting", "ფორმატირების გარეშე ჩასმა"), + ("Place", "განთავსება"), + ("Place Caret", "კურსორის დასმა"), + ("Place Item", "ელემენტის განთავსება"), + ("Place Snippet", "ფრაგმენტის განთავსება"), + ("Place and Link", "განთავსება და დაკავშირება"), + ("Place with Import Options…", "განთავსება იმპორტის პარამეტრებით…"), + ("Polygon Settings…", "მრავალკუთხედის პარამეტრები…"), + ("Preflight Document", "დოკუმენტის წინასწარი შემოწმება"), + ("Previous Cell", "წინა უჯრა"), + ("Previous Document", "წინა დოკუმენტი"), + ("Previous Object in Group", "ჯგუფის წინა ობიექტი"), + ("Primary Text Frame", "ძირითადი ტექსტური ჩარჩო"), + ("Print Booklet…", "ბროშურის დაბეჭდვა…"), + ("Printers", "პრინტერები"), + ("Proof Colors", "ფერების საცდელი ჩვენება"), + ("Proof Setup", "საცდელი ჩვენების პარამეტრები"), + ("Quick Apply…", "სწრაფი გამოყენება…"), + ("Recover Documents", "დოკუმენტების აღდგენა"), + ("Recovery Data", "აღდგენის მონაცემები"), + ("Reject All Changes", "ყველა ცვლილების უარყოფა"), + ("Release Compound Path", "შედგენილი ტრაექტორიის მოხსნა"), + ("Remove All Local Overrides", "ყველა ლოკალური გადაფარვის მოხსნა"), + ("Remove Document", "დოკუმენტის მოცილება"), + ("Report an Issue…", "ხარვეზის შეტყობინება…"), + ("Reset Shortcuts", "მალსახმობების ჩამოყრა"), + ("Reset Workspace", "სამუშაო სივრცის ჩამოყრა"), + ("Resize", "ზომის შეცვლა"), + ("Rotate Spread", "გაშლის შემობრუნება"), + ("Run Script", "სკრიპტის გაშვება"), + ("Sample Document", "სანიმუშო დოკუმენტი"), + ("Satin", "ატლასი"), + ("Save Recovery Data", "აღდგენის მონაცემების შენახვა"), + ("Script Label", "სკრიპტის ჭდე"), + ("Select All on Layer", "შრეზე ყველაფრის მონიშვნა"), + ("Select Cells", "უჯრების მონიშვნა"), + ("Select Column", "სვეტის მონიშვნა"), + ("Select Frame", "ჩარჩოს მონიშვნა"), + ("Select Row", "ცხრილის სტრიქონის მონიშვნა"), + ("Select Table", "ცხრილის მონიშვნა"), + ("Select Text", "ტექსტის მონიშვნა"), + ("Select Tool", "ხელსაწყოს არჩევა"), + ("Select Word", "სიტყვის მონიშვნა"), + ("Selection Attributes", "მონიშნულის ატრიბუტები"), + ("Separations Preview", "სეპარაციის წინასწარი ნახვა"), + ("Serialize", "სერიალიზაცია"), + ("Set Active Layer", "აქტიური შრის დაყენება"), + ("Set Shortcut", "მალსახმობის მინიჭება"), + ("Set Visibility / Lock", "ხილვადობის / ჩაკეტვის დაყენება"), + ("Show Panel", "პანელის ჩვენება"), + ("Show/Hide Baseline Grid", "საბაზისო ხაზების ბადის ჩვენება/დამალვა"), + ("Show/Hide Frame Edges", "ჩარჩოს კიდეების ჩვენება/დამალვა"), + ("Show/Hide Guides", "დამხმარე ხაზების ჩვენება/დამალვა"), + ("Show/Hide Hidden Characters", "დამალული სიმბოლოების ჩვენება/დამალვა"), + ("Show/Hide Panels", "პანელების ჩვენება/დამალვა"), + ("Show/Hide Panels Except Tools", "პანელების ჩვენება/დამალვა, ხელსაწყოების გარდა"), + ("Show/Hide Rulers", "სახაზავების ჩვენება/დამალვა"), + ("Show/Hide Tagged Frames", "ტეგირებული ჩარჩოების ჩვენება/დამალვა"), + ("Show/Hide Text Threads", "ტექსტის კავშირების ჩვენება/დამალვა"), + ("Smooth", "გლუვი"), + ("Sort", "სორტირება"), + ("Split Cell Horizontally", "უჯრის ჰორიზონტალურად გაყოფა"), + ("Split Cell Vertically", "უჯრის ვერტიკალურად გაყოფა"), + ("Split Path", "ტრაექტორიის გაყოფა"), + ("Step and Repeat…", "ბიჯით გამეორება…"), + ("Stroke Styles", "კონტურის სტილები"), + ("Structure", "სტრუქტურა"), + ("Style Source", "სტილების წყარო"), + ("Synchronize Book", "წიგნის სინქრონიზაცია"), + ("Table Options", "ცხრილის პარამეტრები"), + ("Table Panel", "ცხრილის პანელი"), + ("Table Style Options…", "ცხრილის სტილის პარამეტრები…"), + ("Table of Contents Entries", "სარჩევის ჩანაწერები"), + ("Table of Contents…", "სარჩევი…"), + ("Text Anchors", "ტექსტის ღუზები"), + ("Text Variables", "ტექსტური ცვლადები"), + ("Toggle Normal/Preview", "ჩვეულებრივი რეჟიმი/წინასწარი ნახვა"), + ("Toggle Selection", "მონიშვნის გადართვა"), + ("Tools: Double Column", "ხელსაწყოები: ორ სვეტად"), + ("Transform Again", "გარდაქმნის გამეორება"), + ("Transform Panel", "გარდაქმნის პანელი"), + ("Transform Values", "გარდაქმნის მნიშვნელობები"), + ("Transparency Blend Space", "გამჭვირვალობის შერევის ფერთა სივრცე"), + ("Transparency Flattener Presets", "გამჭვირვალობის გაბრტყელების პრესეტები"), + ("Type on a Path", "ტექსტი ტრაექტორიაზე"), + ("Type on a Path Options…", "ტრაექტორიაზე ტექსტის პარამეტრები…"), + ("Typical Display", "ტიპური ჩვენება"), + ("UI Scaling", "ინტერფეისის მასშტაბირება"), + ("Update Link", "ბმულის განახლება"), + ("User Dictionary Words", "მომხმარებლის ლექსიკონის სიტყვები"), + ("User Dictionary…", "მომხმარებლის ლექსიკონი…"), + ("Zoom To", "მასშტაბი"), + ("⇧", "⇧"), + ("⌃", "⌃"), + ("⌘", "⌘"), + ("⌥", "⌥"), + ("Accept Change", "ცვლილების მიღება"), + ("Add Anchor", "ღუზის დამატება"), + ("Add Selection to Article", "მონიშნულის სტატიაში დამატება"), + ("Add Unnamed Colors", "უსახელო ფერების დამატება"), + ("Add to Dictionary", "ლექსიკონში დამატება"), + ("Align Center", "ცენტრში სწორება"), + ("Align Left", "მარცხნივ სწორება"), + ("Align Right", "მარჯვნივ სწორება"), + ("Apply Character Style", "სიმბოლოს სტილის გამოყენება"), + ("Apply Object Style", "ობიექტის სტილის გამოყენება"), + ("Apply Paragraph Style", "აბზაცის სტილის გამოყენება"), + ("Apply Table Style", "ცხრილის სტილის გამოყენება"), + ("Article Options", "სტატიის პარამეტრები"), + ("Basic Feather", "კიდეების მარტივი შერბილება"), + ("Bold", "მუქი"), + ("Bring Forward", "წინ გადმოტანა"), + ("Bring to Front", "წინა პლანზე გადმოტანა"), + ("Change", "ჩანაცვლება"), + ("Character Style Options…", "სიმბოლოს სტილის პარამეტრები…"), + ("Close Path", "ტრაექტორიის შეკვრა"), + ("Color Group Options", "ფერების ჯგუფის პარამეტრები"), + ("Column Width", "სვეტის სიგანე"), + ("Column Rules", "სვეტებს შორის ხაზები"), + ("Insert Column Rule", "სვეტებს შორის ხაზის ჩასმა"), + ("Tint %", "ტონირება %"), + ("Top Inset", "ზედა შეწევა"), + ("Bottom Inset", "ქვედა შეწევა"), + ("Horizontal Offset", "ჰორიზონტალური წანაცვლება"), + ("Condition Options…", "პირობის პარამეტრები…"), + ("Convert to Liquid Guide", "მოქნილ დამხმარე ხაზად გარდაქმნა"), + ("Convert to Object", "ობიექტად გარდაქმნა"), + ("Create Line", "ხაზის შექმნა"), + ("Create Path", "ტრაექტორიის შექმნა"), + ("Decrease Point Size", "შრიფტის ზომის შემცირება"), + ("Delete Article", "სტატიის წაშლა"), + ("Delete Bookmark", "სანიშნის წაშლა"), + ("Delete Endnote", "დოკუმენტის სქოლიოს წაშლა"), + ("Delete Footnote", "სქოლიოს წაშლა"), + ("Delete Guide", "დამხმარე ხაზის წაშლა"), + ("Delete Hyperlink", "ჰიპერბმულის წაშლა"), + ("Delete List", "სიის წაშლა"), + ("Delete Note", "შენიშვნის წაშლა"), + ("Delete Pages", "გვერდების წაშლა"), + ("Delete Paragraph Style", "აბზაცის სტილის წაშლა"), + ("Delete Stroke Style", "კონტურის სტილის წაშლა"), + ("Delete Table", "ცხრილის წაშლა"), + ("Delete Tag", "ტეგის წაშლა"), + ("Delete Text", "ტექსტის წაშლა"), + ("Distribute Columns Evenly", "სვეტების თანაბრად განაწილება"), + ("Edit Endnote", "დოკუმენტის სქოლიოს რედაქტირება"), + ("Edit Note", "შენიშვნის რედაქტირება"), + ("Embed Link", "ბმული ფაილის ჩაშენება"), + ("Fill", "შევსება"), + ("Fill with Placeholder Text", "შემავსებელი ტექსტით შევსება"), + ("Flip", "არეკვლა"), + ("Hyperlink Options…", "ჰიპერბმულის პარამეტრები…"), + ("Increase Point Size", "შრიფტის ზომის გაზრდა"), + ("Insert Column Left", "სვეტის ჩასმა მარცხნივ"), + ("Insert Column Right", "სვეტის ჩასმა მარჯვნივ"), + ("Insert Row Above", "სტრიქონის ჩასმა ზემოთ"), + ("Insert Row Below", "სტრიქონის ჩასმა ქვემოთ"), + ("Italic", "დახრილი"), + ("Justify Left", "ორმხრივი სწორება, ბოლო სტრიქონი მარცხნივ"), + ("Layer Options…", "შრის პარამეტრები…"), + ("Move", "გადაადგილება"), + ("Move Anchors", "საყრდენი წერტილების გადაადგილება"), + ("Move Column", "სვეტის გადატანა"), + ("Move Guide", "დამხმარე ხაზის გადაადგილება"), + ("Move Layer", "შრის გადაადგილება"), + ("Move Pages…", "გვერდების გადატანა…"), + ("Move to Layer", "შრეზე გადატანა"), + ("New Article", "ახალი სტატია"), + ("New Bookmark", "ახალი სანიშნე"), + ("New Character Style…", "სიმბოლოს ახალი სტილი…"), + ("New Layer…", "ახალი შრე…"), + ("New Note", "ახალი შენიშვნა"), + ("New Object Style…", "ობიექტის ახალი სტილი…"), + ("New Parent…", "ახალი საბაზო გვერდი…"), + ("New Tag", "ახალი ტეგი"), + ("Opacity", "გაუმჭვირვალობა"), + ("Paste in Place", "ადგილზე ჩასმა"), + ("Place Loaded Graphic", "ჩატვირთული გრაფიკის განთავსება"), + ("Reject Change", "ცვლილების უარყოფა"), + ("Release State to Objects", "მდგომარეობის ობიექტებად გარდაქმნა"), + ("Remove Hyphenation Exception", "გადატანის გამონაკლისის მოცილება"), + ("Remove from Article", "სტატიიდან მოცილება"), + ("Rename", "სახელის შეცვლა"), + ("Rename Bookmark", "სანიშნის სახელის შეცვლა"), + ("Rotate", "შემობრუნება"), + ("Row Height", "სტრიქონის სიმაღლე"), + ("Scale", "მასშტაბირება"), + ("Send Backward", "უკან გადატანა"), + ("Send to Back", "უკანა პლანზე გადატანა"), + ("Set Cross-Reference Format", "ჯვარედინი მიმართვის ფორმატის დაყენება"), + ("Set Footnote Text", "სქოლიოს ტექსტის დაყენება"), + ("Set Story Text", "ტექსტური ნაკადის ტექსტის დაყენება"), + ("Shear", "დახრა"), + ("Show All on Spread", "გაშლაზე ყველაფრის ჩვენება"), + ("Show State", "მდგომარეობის ჩვენება"), + ("State Options", "მდგომარეობის პარამეტრები"), + ("Tag Frame", "ჩარჩოსთვის ტეგის მინიჭება"), + ("Tag Text", "ტექსტისთვის ტეგის მინიჭება"), + ("Track Changes", "ცვლილებების ჩაწერა"), + ("Unlink", "კავშირის გაწყვეტა"), + ("Unlock All on Spread", "გაშლაზე ყველაფრის განბლოკვა"), + ("Update Index", "ინდექსის განახლება"), + ("Update Table of Contents", "სარჩევის განახლება"), + ("Bulleted and Numbered Lists", "ბურთულებიანი და დანომრილი სიები"), + ("Insert Special Character", "სპეციალური სიმბოლოს ჩასმა"), + ("Insert White Space", "ჰარის სიმბოლოს ჩასმა"), + ("Insert Break Character", "გამყოფი სიმბოლოს ჩასმა"), + ("Hyperlinks Cross-References", "ჰიპერბმულები, ჯვარედინი მიმართვები"), + ("Document Footnote Options...", "დოკუმენტის სქოლიოების პარამეტრები..."), + (" Close ", " დახურვა "), + (" New file ", " ახალი ფაილი "), + (" OK ", " OK "), + (" Open ", " გახსნა "), + (" Open sample magazine ", " სანიმუშო ჟურნალის გახსნა "), + ("(Top Level)", "(ზედა დონე)"), + ("(import)", "(იმპორტი)"), + ("Anchored Object", "მიმაგრებული ობიექტი"), + ("Apply Attributes (Eyedropper", "ატრიბუტების გამოყენება (პიპეტი"), + ("Baseline Grid", "საბაზისო ხაზების ბადე"), + ("Bleed and Slug", "ჭრისპირა არე და საინფორმაციო ზოლი"), + ("Buttons act in interactive PDF export.", "ღილაკები მოქმედებს ინტერაქტიური PDF-ის ექსპორტისას."), + ("Captions", "წარწერები"), + ("Change All (Object", "ყველას ჩანაცვლება (ობიექტი"), + ("Character Settings", "სიმბოლოს პარამეტრები"), + ("Choose a link.", "აირჩიეთ ბმული."), + ("Clipping Path", "ჩაჭრის ტრაექტორია"), + ( + "Collect objects with the Content Collector (B); place them with the Content Placer.", + "შეაგროვეთ ობიექტები შიგთავსის შემგროვებლით (B) და განათავსეთ შიგთავსის განმათავსებლით.", + ), + ("Content Fitting", "შიგთავსის ზომაზე მორგება"), + ("Convert Cell Type", "უჯრის ტიპის გარდაქმნა"), + ("Copy Link(s", "ბმული ფაილების კოპირება"), + ("Create Alternate Layout", "ალტერნატიული განლაგების შექმნა"), + ("Crop Amount", "ჩამოჭრის ზომა"), + ("Cross-Reference Format", "ჯვარედინი მიმართვის ფორმატი"), + ("Delete Workspace", "სამუშაო სივრცის წაშლა"), + ("DesignCraft", "DesignCraft"), + ("Display Performance", "ჩვენების წარმადობა"), + ("Document Grid", "დოკუმენტის ბადე"), + ("Document Recovery Data", "დოკუმენტის აღდგენის მონაცემები"), + ("EPUB and HTML", "EPUB და HTML"), + ("Export", "ექსპორტი"), + ("Export EPUB (Fixed Layout", "EPUB ექსპორტი (ფიქსირებული განლაგება"), + ("Export EPUB (Reflowable", "EPUB ექსპორტი (ადაპტური განლაგება"), + ("Facing Pages", "მოპირდაპირე გვერდები"), + ("GREP Styles", "GREP სტილები"), + ( + "Get help, share your layouts and shape what we build next.", + "მიიღეთ დახმარება, გააზიარეთ თქვენი განლაგებები და გავლენა მოახდინეთ იმაზე, რას შევქმნით შემდეგ.", + ), + ("Highlight", "გამოკვეთა"), + ("Hyperlinks & Cross-References", "ჰიპერბმულები და ჯვარედინი მიმართვები"), + ("Hyphens and Dashes", "დეფისები და ტირეები"), + ("Interactive", "ინტერაქტიური"), + ("Invert", "ინვერსია"), + ("Join the ArtCraft community", "შემოუერთდით ArtCraft-ის საზოგადოებას"), + ("Keyboard Increments", "კლავიატურით ბიჯები"), + ("Liquid guides", "მოქნილი დამხმარე ხაზები"), + ("Markers", "მარკერები"), + ("More ArtCraft apps", "ArtCraft-ის სხვა აპლიკაციები"), + ("Nested Line Styles", "ხაზის ჩადგმული სტილები"), + ("Nested Styles", "ჩადგმული სტილები"), + ("No articles: exports follow page order.", "სტატიები არ არის: ექსპორტი გვერდების რიგს მიჰყვება."), + ("No book open.", "წიგნი გახსნილი არ არის."), + ("No bookmarks. They become the PDF outline.", "სანიშნეები არ არის. ისინი PDF-ის სარჩევად იქცევა."), + ("No changes.", "ცვლილებები არ არის."), + ( + "No conditions. Create one, then select text and click it to apply.", + "პირობები არ არის. შექმენით პირობა, შემდეგ მონიშნეთ ტექსტი და დაწკაპეთ პირობაზე მის გამოსაყენებლად.", + ), + ("No document open", "დოკუმენტი გახსნილი არ არის"), + ("No document.", "დოკუმენტი არ არის."), + ( + "No hyperlinks. Select text or a frame, type a URL and click New.", + "ჰიპერბმულები არ არის. მონიშნეთ ტექსტი ან ჩარჩო, შეიყვანეთ URL და დაწკაპეთ „ახალი“-ზე.", + ), + ("No library open.", "ბიბლიოთეკა გახსნილი არ არის."), + ("No notes in this document.", "ამ დოკუმენტში შენიშვნები არ არის."), + ("No placed graphics.", "განთავსებული გრაფიკა არ არის."), + ("No scripts. Add one below.", "სკრიპტები არ არის. დაამატეთ ქვემოთ."), + ("No text anchors in this document.", "ამ დოკუმენტში ტექსტის ღუზები არ არის."), + ("OK changes all: text and paragraph/character styles.", "OK ყველაფერს ცვლის: ტექსტს და აბზაცის/სიმბოლოს სტილებს."), + ("OVERSET", "ტექსტი არ ეტევა"), + ("Open source. No telemetry.", "ღია კოდი. ტელემეტრიის გარეშე."), + ("Options", "პარამეტრები"), + ("Options for Black on RGB and Grayscale Devices", "შავის პარამეტრები RGB და რუხი ტონების მოწყობილობებისთვის"), + ("Other", "სხვა"), + ("Output", "გამოტანა"), + ("Page Numbering", "გვერდების დანომვრა"), + ( + "Page layout for print and screen — fast, open and scriptable.", + "გვერდების განლაგება ბეჭდვისა და ეკრანისთვის — სწრაფი, ღია და სკრიპტებით მართვადი.", + ), + ("Page · PPI", "გვერდი · PPI"), + ("Part of the ArtCraft suite of open creative tools.", "ArtCraft-ის ღია შემოქმედებითი ხელსაწყოების ნაკრების ნაწილი."), + ("Pasteboard Options", "სამუშაო არის პარამეტრები"), + ("Paths", "ტრაექტორიები"), + ("Place the insertion point in a table, or create one.", "მოათავსეთ კურსორი ცხრილში ან შექმენით ცხრილი."), + ("Plays in EPUB export; print and PDF show the poster.", "იკვრება EPUB ექსპორტში; ბეჭდვასა და PDF-ში პოსტერი ჩანს."), + ("Quotation Marks", "ბრჭყალები"), + ("Recently Used", "ბოლოს გამოყენებული"), + ("Replace With", "ჩანაცვლება"), + ("Right-click: stroke, groups", "მარჯვენა დაწკაპუნება: კონტური, ჯგუფები"), + ("Ruler Units", "სახაზავის ერთეულები"), + ("Saves which bars show and where the panels are.", "ინახავს, რომელი ზოლები ჩანს და სად არის პანელები."), + ( + "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", + "მონიშნეთ განთავსებული ვიდეო ან ხმა (ფაილი › განთავსება: .mp4, .mov, .mp3, .wav …).", + ), + ("Select an object to color its fill or stroke.", "მონიშნეთ ობიექტი მისი შევსების ან კონტურის ფერის შესაცვლელად."), + ("Select an object to give its fill a gradient.", "მონიშნეთ ობიექტი მისი შევსებისთვის გრადიენტის მისაცემად."), + ("Select an object to make it a button.", "მონიშნეთ ობიექტი მის ღილაკად გადასაქცევად."), + ( + "Select two or more objects (or a group) to make a multi-state object.", + "მრავალმდგომარეობიანი ობიექტის შესაქმნელად მონიშნეთ ორი ან მეტი ობიექტი (ან ჯგუფი).", + ), + ("Show", "ჩვენება"), + ("Smart Text Reflow", "ტექსტის ჭკვიანი გადადინება"), + ("Spelling", "მართლწერა"), + ("Start a new document", "ახალი დოკუმენტის შექმნა"), + ("Status", "სტატუსი"), + ("Style Mapping", "სტილების შესაბამისობა"), + ("Style Name Conflicts", "სტილების სახელების კონფლიქტები"), + ("Symbols", "სიმბოლოები"), + ("System default", "სისტემის ნაგულისხმევი"), + ("Table Dimensions", "ცხრილის ზომები"), + ("Text Display Options", "ტექსტის ჩვენების პარამეტრები"), + ("The fill isn't a gradient.", "შევსება გრადიენტი არ არის."), + ("The library is empty. Select objects and Add Selection.", "ბიბლიოთეკა ცარიელია. მონიშნეთ ობიექტები და დაწკაპეთ „მონიშნულის დამატება“-ზე."), + ("Transitions play in interactive PDF (full-screen mode).", "გადასვლები იკვრება ინტერაქტიურ PDF-ში (სრულეკრანიან რეჟიმში)."), + ("Type & Tables", "ტექსტი და ცხრილები"), + ("Type Options", "ტექსტის პარამეტრები"), + ( + "Type a word with ~ at each allowed break (ex~am~ple); without ~ the word is never hyphenated.", + "აკრიფეთ სიტყვა ~ ნიშნით ყოველ დასაშვებ გადატანის ადგილას (მა~გა~ლი~თი); ~-ის გარეშე სიტყვა არასოდეს გადაიტანება.", + ), + ("User Dictionary (this document)", "მომხმარებლის ლექსიკონი (ეს დოკუმენტი)"), + ("Utilities", "დამხმარე საშუალებები"), + ("Variable Font", "ცვლადი შრიფტი"), + ("Welcome to DesignCraft", "მოგესალმებათ DesignCraft"), + ("When Scaling", "მასშტაბირებისას"), + ("[Basic] (working) ▾", "[საბაზო] (სამუშაო) ▾"), + ("[Black] Overprint", "[შავი] — ზედდაბეჭდვა"), + ("[No Group]", "[ჯგუფის გარეშე]"), + ("[None]", "[არცერთი]"), + ("[None] — choose a colour on the ramp.", "[არცერთი] — აირჩიეთ ფერი სკალაზე."), + ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — დახმარება, გამოხმაურება და ნამუშევრების ჩვენება"), + ("⌥-click clears overrides", "⌥-დაწკაპუნება ასუფთავებს გადაფარვებს"), + ("⚠ out of date", "⚠ მოძველებულია"), + ("General", "ზოგადი"), + ("Basic Character Formats", "სიმბოლოს საბაზო ფორმატები"), + ("Indents and Spacing", "შეწევა და ინტერვალი"), + ("Hyphenation", "სიტყვების გადატანა"), + ("Justification", "სწორება"), + ("Drop Caps and Nested Styles", "ინიციალები და ჩადგმული სტილები"), + ("GREP Style", "GREP სტილი"), + ("Character Color", "სიმბოლოს ფერი"), + ("Numbering and Formatting", "დანომვრა და ფორმატირება"), + ("Spacing Options", "დაშორების პარამეტრები"), + ("First Baseline", "პირველი საბაზისო ხაზი"), + ("Rule Above", "ხაზი ზემოთ"), + ("Numbering", "დანომვრა"), + ("Footnote Reference Number in Text", "სქოლიოს მითითების ნომერი ტექსტში"), + ("Footnote Formatting", "სქოლიოს ფორმატირება"), + ("Span Footnotes Across Columns", "სქოლიოების სვეტებზე გაშლა"), + ("Rule On", "ხაზი ჩართულია"), + ("Ascent", "ზედა გამონაშვერი"), + ("Cap Height", "მთავრული ასოს სიმაღლე"), + ("Leading", "სტრიქონთაშორისი ინტერვალი"), + ("x Height", "x-სიმაღლე"), + ("Fixed", "ფიქსირებული"), + ("Never (continuous)", "არასოდეს (უწყვეტი)"), + ("Page", "გვერდი"), + ("Spread", "გაშლა"), + ("Section", "სექცია"), + ("Footnote Reference", "სქოლიოს მითითება"), + ("Footnote Text", "სქოლიოს ტექსტი"), + ("Both", "ორივე"), + ("Apply Superscript", "ზედა ინდექსის გამოყენება"), + ("Apply Subscript", "ქვედა ინდექსის გამოყენება"), + ("Apply Normal", "ჩვეულებრივის გამოყენება"), + ("OpenType Superscript", "OpenType ზედა ინდექსი"), + ("through", "ჩათვლით"), + ("up to", "ჩაუთვლელად"), + ("line", "სტრიქონი"), + ("lines", "სტრიქონი"), + ("sentences", "წინადადება"), + ("words", "სიტყვა"), + ("characters", "სიმბოლო"), + ("letters", "ასო"), + ("digits", "ციფრი"), + ("tab", "ტაბულაცია"), + ("forcedLineBreak", "სტრიქონის იძულებითი გადატანა"), + ("emSpace", "გრძელი შორისი"), + ("enSpace", "მოკლე შორისი"), + ("chars", "სიმბოლო"), + ("[Automatic]", "[ავტომატური]"), + ("Tracking:", "ტრეკინგი:"), + ("Right Indent:", "მარჯვენა შეწევა:"), + ("First Line Indent:", "პირველი სტრიქონის შეწევა:"), + ("Space Before:", "დაშორება წინ:"), + ("Space After:", "დაშორება შემდეგ:"), + ("Words with at Least:", "სიტყვები მინიმუმ:"), + ("After First:", "პირველის შემდეგ:"), + ("Before Last:", "ბოლომდე:"), + ("Hyphen Limit:", "გადატანების ზღვარი:"), + ("Word Spacing:", "სიტყვებს შორის დაშორება:"), + ("Letter Spacing:", "ასოებს შორის დაშორება:"), + ("Glyph Scaling:", "გლიფების მასშტაბირება:"), + ("Insert Kashidas:", "ქაშიდების ჩასმა:"), + ("In justified Arabic text", "ორმხრივად სწორებულ არაბულ ტექსტში"), + ("Korean Line Breaks:", "კორეული სტრიქონების წყვეტა:"), + ("Between syllables (not only at spaces)", "მარცვლებს შორის (არა მხოლოდ ჰარებთან)"), + ("Binding:", "აკინძვა:"), + ("Ruby:", "რუბი:"), + ("Set over the selected text; empty removes it.", "დაისმება მონიშნული ტექსტის თავზე; ცარიელი წაშლის მას."), + ("[Basic Paragraph]", "[საბაზო აბზაცი]"), + ("[No Paragraph Style]", "[აბზაცის სტილის გარეშე]"), + ("Align left", "მარცხნივ სწორება"), + ("Align center", "ცენტრში სწორება"), + ("Align right", "მარჯვნივ სწორება"), + ("Justify with last line aligned left", "ორმხრივი სწორება, ბოლო სტრიქონი მარცხნივ"), + ("Justify with last line aligned center", "ორმხრივი სწორება, ბოლო სტრიქონი ცენტრში"), + ("Justify with last line aligned right", "ორმხრივი სწორება, ბოლო სტრიქონი მარჯვნივ"), + ("Justify all lines", "ყველა სტრიქონის ორმხრივი სწორება"), + ("Align towards spine", "ყუისკენ სწორება"), + ("Align away from spine", "ყუიდან მოშორებით სწორება"), + ("Create New Style from Selection", "მონიშნულიდან ახალი სტილის შექმნა"), + ("No text selected", "ტექსტი მონიშნული არ არის"), + ("Search", "ძიება"), + ("Essentials", "ძირითადი"), + ("Advanced", "დამატებითი"), + ("Digital Publishing", "ციფრული გამოცემა"), + ("Interactive for PDF", "ინტერაქტიური PDF-ისთვის"), + ("Printing and Proofing", "ბეჭდვა და კორექტურა"), + ("Typography", "ტიპოგრაფია"), + ("Rulers & Grids", "სახაზავები და ბადეები"), + ("No errors", "შეცდომები არ არის"), + ("error", "შეცდომა"), + ("errors", "შეცდომა"), + ("Reset", "ჩამოყრა"), + ("Right to Left", "მარჯვნიდან მარცხნივ"), + ("Left to Right", "მარცხნიდან მარჯვნივ"), + ("Portrait", "ვერტიკალური"), + ("Landscape", "ჰორიზონტალური"), + ("Display All Blacks as Rich Black", "ყველა შავის მდიდარ შავად ჩვენება"), + ("Display All Blacks Accurately", "ყველა შავის ზუსტად ჩვენება"), + ("Output All Blacks as Rich Black", "ყველა შავის მდიდარ შავად გამოტანა"), + ("Output All Blacks Accurately", "ყველა შავის ზუსტად გამოტანა"), + ("Top Margin", "ზედა ველი"), + ("Top of Page", "გვერდის თავში"), + ("Paragraph Styles panel", "აბზაცის სტილების პანელი"), + ("Character Styles panel", "სიმბოლოს სტილების პანელი"), + ("Create new style", "ახალი სტილის შექმნა"), + ("Clear overrides", "გადაფარვების გასუფთავება"), + ("Apply", "გამოყენება"), + ("Accept", "მიღება"), + ("Reject", "უარყოფა"), + ("Add Selection", "მონიშნულის დამატება"), + ("Align Stroke:", "კონტურის სწორება:"), + ("Angle", "კუთხე"), + ("Angle:", "კუთხე:"), + ("Baseline", "საბაზისო ხაზი"), + ("Cap:", "დაბოლოება:"), + ("Cell Text", "უჯრის ტექსტი"), + ("Cell Type", "უჯრის ტიპი"), + ("Composer", "აწყობის რეჟიმი"), + ("Convert to Text", "ტექსტად გარდაქმნა"), + ("Create New Layer", "ახალი შრის შექმნა"), + ("Delete DTD", "DTD-ის წაშლა"), + ("Digits", "ციფრები"), + ("Direction", "მიმართულება"), + ("Distance", "მანძილი"), + ("Distribute ↔", "განაწილება ↔"), + ("Distribute ↕", "განაწილება ↕"), + ("Effects: Drop Shadow", "ეფექტები: ჩრდილი"), + ("Favorites", "რჩეულები"), + ("First Spread", "პირველი გაშლა"), + ("Gap Color:", "შუალედის ფერი:"), + ("Graphic…", "გრაფიკა…"), + ("Home", "საწყისი ეკრანი"), + ("Join:", "შეერთება:"), + ("Language", "ენა"), + ("Last Spread", "ბოლო გაშლა"), + ("Load DTD…", "DTD-ის ჩატვირთვა…"), + ("Location:", "მდებარეობა:"), + ("Lock Guides", "დამხმარე ხაზების ჩაკეტვა"), + ("Miter Limit:", "მახვილის ზღვარი:"), + ("New Article from Selection", "ახალი სტატია მონიშნულიდან"), + ("New Note at Cursor", "ახალი შენიშვნა კურსორთან"), + ("New Swatch", "ფერის ახალი ნიმუში"), + ("New condition name", "ახალი პირობის სახელი"), + ("New group…", "ახალი ჯგუფი…"), + ("New tag", "ახალი ტეგი"), + ("Next Spread", "შემდეგი გაშლა"), + ("Note text", "შენიშვნის ტექსტი"), + ("Offset", "წანაცვლება"), + ("Offsets", "წანაცვლებები"), + ("Paragraph alignment", "აბზაცის სწორება"), + ("Previous Spread", "წინა გაშლა"), + ("Release to Objects", "ობიექტებად დაშლა"), + ("Remove", "მოცილება"), + ("Rotate 90° Counterclockwise", "90°-ით შემობრუნება საათის ისრის საწინააღმდეგოდ"), + ("Rotation Angle", "შემობრუნების კუთხე"), + ("Rows", "სტრიქონები"), + ("Run", "გაშვება"), + ("Scale X Percentage", "მასშტაბი X, პროცენტი"), + ("Scale Y Percentage", "მასშტაბი Y, პროცენტი"), + ("Search commands", "ბრძანებების ძიება"), + ("Search fonts", "შრიფტების ძიება"), + ("Search styles and commands…", "სტილებისა და ბრძანებების ძიება…"), + ("Search: character or U+code", "ძიება: სიმბოლო ან U+კოდი"), + ("Share", "გაზიარება"), + ("Shear X Angle", "დახრის კუთხე X"), + ("Show Baseline Grid", "საბაზისო ხაზების ბადის ჩვენება"), + ("Show Document Grid", "დოკუმენტის ბადის ჩვენება"), + ("Show Guides", "დამხმარე ხაზების ჩვენება"), + ("Stop Color:", "გადასვლის წერტილის ფერი:"), + ("Style", "სტილი"), + ("Styles panel", "სტილების პანელი"), + ("Text", "ტექსტი"), + ("The structure is valid.", "სტრუქტურა სწორია."), + ("Toggle double column", "ორ სვეტად ჩვენების გადართვა"), + ("Tracking", "ტრეკინგი"), + ("Untag Selection", "მონიშნულისთვის ტეგის მოხსნა"), + ("Validate", "შემოწმება"), + ("Weight", "სისქე"), + ("Subtract", "გამოკლება"), + ("Intersect", "გადაკვეთა"), + ("Exclude Overlap", "გადაფარვის გამორიცხვა"), + ("Minus Back", "უკანას გამოკლება"), + ("Align left edges", "მარცხენა კიდეების სწორება"), + ("Align horizontal centers", "ჰორიზონტალური ცენტრების სწორება"), + ("Align right edges", "მარჯვენა კიდეების სწორება"), + ("Align top edges", "ზედა კიდეების სწორება"), + ("Align vertical centers", "ვერტიკალური ცენტრების სწორება"), + ("Align bottom edges", "ქვედა კიდეების სწორება"), + ("Align to Selection", "მონიშნულზე სწორება"), + ("Align to Key Object", "საკვანძო ობიექტზე სწორება"), + ("Align to Margins", "ველებზე სწორება"), + ("Align to Page", "გვერდზე სწორება"), + ("Align to Spread", "გაშლაზე სწორება"), + ("No Text Wrap", "ტექსტით გარშემოვლების გარეშე"), + ("Wrap Around Bounding Box", "შემომსაზღვრელი ჩარჩოს ტექსტით გარშემოვლება"), + ("Wrap Around Object Shape", "ობიექტის ფორმის ტექსტით გარშემოვლება"), + ("Jump Object", "ობიექტზე გადახტომა"), + ("Jump to Next Column", "შემდეგ სვეტზე გადასვლა"), + ("Bulleted List", "ბურთულებიანი სია"), + ("Numbered List", "დანომრილი სია"), + ("UPPERCASE", "მთავრული"), + ("Small Caps", "პატარა მთავრული"), + ("Normal", "ჩვეულებრივი"), + ("Butt", "ბრტყელი"), + ("Round", "მომრგვალებული"), + ("Projecting", "გამოშვერილი"), + ("Miter", "მახვილი"), + ("Bevel", "წაკვეთილი"), + ("Center", "ცენტრში"), + ("Start/End:", "დასაწყისი/დასასრული:"), + ("Left", "მარცხნივ"), + ("Right", "მარჯვნივ"), + ("First", "პირველი"), + ("Last", "ბოლო"), + ("Before", "წინ"), + ("After", "შემდეგ"), + ("Shading", "ფონი"), + ("Border", "ჩარჩო"), + ("Overprint Fill", "შევსების ზედდაბეჭდვა"), + ("Overprint Stroke", "კონტურის ზედდაბეჭდვა"), + ("Overprint Gap", "ღრეჩოს ზედდაბეჭდვა"), + ("Nonprinting", "არ იბეჭდება"), + ("At Least", "მინიმუმ"), + ("Exactly", "ზუსტად"), + ("Justify", "ორმხრივი სწორება"), + ("Underline Options", "ხაზგასმის პარამეტრები"), + ("Strikethrough Options", "გადახაზვის პარამეტრები"), + ("Character Style Options", "სიმბოლოს სტილის პარამეტრები"), + ("Advanced Character Formats", "სიმბოლოს დამატებითი ფორმატები"), + ("OpenType Features", "OpenType შესაძლებლობები"), + ("Style Settings:", "სტილის პარამეტრები:"), + ("Kerning:", "კერნინგი:"), + ("Case:", "რეგისტრი:"), + ("OpenType All Small Caps", "OpenType: ყველა პატარა მთავრული"), + ("Subscript", "ქვედა ინდექსი"), + ("OpenType Subscript", "OpenType ქვედა ინდექსი"), + ("OpenType Numerator", "OpenType მრიცხველი"), + ("OpenType Denominator", "OpenType მნიშვნელი"), + ("Ligatures", "ლიგატურები"), + ("No Break", "გადატანის გარეშე"), + ("Horizontal Scale:", "ჰორიზონტალური მასშტაბი:"), + ("Vertical Scale:", "ვერტიკალური მასშტაბი:"), + ("Baseline Shift:", "საბაზისო ხაზის წანაცვლება:"), + ("Skew:", "დახრა:"), + ("Language:", "ენა:"), + ("Figure Style:", "ციფრების სტილი:"), + ("Underline On", "ხაზგასმა ჩართულია"), + ("Strikethrough On", "გადახაზვა ჩართულია"), + ("Edit \"{name}\"…", "„{name}“-ის რედაქტირება…"), + ("Discretionary Ligatures", "დამატებითი ლიგატურები"), + ("Fractions", "წილადები"), + ("Ordinal", "რიგობითი"), + ("Swash", "მოხატული ასოები (swash)"), + ("Titling Alternates", "სათაურის ალტერნატივები"), + ("Contextual Alternates", "კონტექსტური ალტერნატივები"), + ("Slashed Zero", "გადახაზული ნული"), + ("Tabular Lining", "ცხრილური ხაზოვანი"), + ("Proportional Oldstyle", "პროპორციული ძველებური"), + ("Proportional Lining", "პროპორციული ხაზოვანი"), + ("Tabular Oldstyle", "ცხრილური ძველებური"), + ("Default Figure Style", "ციფრების ნაგულისხმევი სტილი"), + ("Not a button", "ღილაკი არ არის"), + ("Go To Next Page", "შემდეგ გვერდზე გადასვლა"), + ("Go To Previous Page", "წინა გვერდზე გადასვლა"), + ("Go To First Page", "პირველ გვერდზე გადასვლა"), + ("Go To Last Page", "ბოლო გვერდზე გადასვლა"), + ("Go To Page", "გვერდზე გადასვლა"), + ("Go To URL", "URL-ზე გადასვლა"), + ("Not a form field", "ფორმის ველი არ არის"), + ("Text Field", "ტექსტური ველი"), + ("Check Box", "მოსანიშნი ველი"), + ("Radio Button", "გადამრთველი"), + ("Combo Box", "ჩამოსაშლელი სია"), + ("List Box", "სიის ველი"), + ("Signature", "ხელმოწერა"), + ("Resize width", "სიგანის შეცვლა"), + ("Resize height", "სიმაღლის შეცვლა"), + ("Pin top", "ზემოთ ჩამაგრება"), + ("Pin bottom", "ქვემოთ ჩამაგრება"), + ("Pin left", "მარცხნივ ჩამაგრება"), + ("Pin right", "მარჯვნივ ჩამაგრება"), + ("Inward", "შიგნით"), + ("Metrics", "მეტრიკა"), + ("Optical", "ოპტიკური"), + ("Applies to fill", "ეხება შევსებას"), + ("Applies to text", "ეხება ტექსტს"), + ("Gray", "ნაცრისფერი"), + ("Linear", "წრფივი"), + ("Radial", "რადიალური"), + ("Arabic", "არაბული"), + ("Beveled Rectangle", "წაკვეთილი მართკუთხედი"), + ("Chinese", "ჩინური"), + ("Constrain proportions for width and height", "სიგანისა და სიმაღლის პროპორციის შენარჩუნება"), + ("Corner", "კუთხე"), + ("Custom", "მორგებული"), + ("Czech", "ჩეხური"), + ("Danish", "დანიური"), + ("Dashed", "ტირეებიანი"), + ("Dotted", "პუნქტირი"), + ("Dutch: 2005 Reform", "ნიდერლანდური: 2005 წლის რეფორმა"), + ("Embedded", "ჩაშენებული"), + ("English: Canadian", "ინგლისური: კანადა"), + ("English: UK", "ინგლისური: ბრიტანეთი"), + ("English: USA", "ინგლისური: აშშ"), + ("Farsi", "სპარსული"), + ("Finnish", "ფინური"), + ("Fit Frame to Content", "ჩარჩოს შიგთავსზე მორგება"), + ("French", "ფრანგული"), + ("French: Canadian", "ფრანგული: კანადა"), + ("German: 2006 Reform", "გერმანული: 2006 წლის რეფორმა"), + ("German: Swiss 2006 Reform", "გერმანული: შვეიცარია, 2006 წლის რეფორმა"), + ("Greek", "ბერძნული"), + ("Hebrew", "ივრითი"), + ("Hindi", "ჰინდი"), + ("Hungarian", "უნგრული"), + ("Inverse Rounded Rectangle", "შებრუნებული მომრგვალებული მართკუთხედი"), + ("Italian", "იტალიური"), + ("Japanese", "იაპონური"), + ("Korean", "კორეული"), + ("Make all settings the same", "ყველა პარამეტრის გატოლება"), + ("Native", "ენის მიხედვით"), + ("Norwegian: Bokmål", "ნორვეგიული: ბუკმოლი"), + ("Orthogonal Line", "ორთოგონალური ხაზი"), + ("Polish", "პოლონური"), + ("Portuguese", "პორტუგალიური"), + ("Portuguese: Brazilian", "პორტუგალიური: ბრაზილია"), + ("Rounded Rectangle", "მომრგვალებული მართკუთხედი"), + ("Russian", "რუსული"), + ("Solid", "უწყვეტი"), + ("Spanish", "ესპანური"), + ("Story Editor", "ტექსტის რედაქტორი"), + ("Swedish", "შვედური"), + ("Thick - Thick", "სქელი - სქელი"), + ("Thick - Thin", "სქელი - თხელი"), + ("Thin - Thick", "თხელი - სქელი"), + ("Thin - Thin", "თხელი - თხელი"), + ("Thin - Thick - Thin", "თხელი - სქელი - თხელი"), + ("Thick - Thin - Thick", "სქელი - თხელი - სქელი"), + ("Wavy", "ტალღოვანი"), + ("Straight Hash", "სწორი დაშტრიხვა"), + ("Stripes", "ზოლები"), + ("Triangle", "სამკუთხედი"), + ("Turkish", "თურქული"), + ("Ukrainian", "უკრაინული"), + ("Georgian", "ქართული"), + ("Cell Style", "უჯრის სტილი"), + ("Table Style", "ცხრილის სტილი"), + ("Col ←", "სვეტი ←"), + ("Col →", "სვეტი →"), + ("Row ↑", "სტრიქონი ↑"), + ("Row ↓", "სტრიქონი ↓"), + ("Hide Others", "სხვების დამალვა"), + ("Lock Others", "დანარჩენების ჩაკეტვა"), + ("Show All Layers", "ყველა შრის ჩვენება"), + ("Unlock", "განბლოკვა"), + ("Unlock All Layers", "ყველა შრის განბლოკვა"), + ("Added", "დამატებულია"), + ("Deleted", "წაშლილია"), + ("Guide-based", "დამხმარე ხაზებზე დაფუძნებული"), + ("Loop", "ციკლი"), + ("Object-based", "ობიექტებზე დაფუძნებული"), + ("Off", "გამორთული"), + ("Play on Page Load", "გვერდის ჩატვირთვისას დაკვრა"), + ("Re-center", "ხელახლა ცენტრირება"), + ("Show Controls", "მართვის ელემენტების ჩვენება"), + ("Signature Field", "ხელმოწერის ველი"), + ("Select text to apply a condition", "პირობის გამოსაყენებლად მონიშნეთ ტექსტი"), + ("Blinds", "ჟალუზები"), + ("Box", "ჩარჩო"), + ("Comb", "სავარცხელი"), + ("Cover", "დაფარვა"), + ("Dissolve", "გაფანტვა"), + ("Fade", "მილევა"), + ("Push", "გადაწევა"), + ("Split", "გაყოფა"), + ("Uncover", "გადახსნა"), + ("Wipe", "წმენდა"), + ("Simple", "მარტივი"), + ("Simple - Wide", "მარტივი - ფართო"), + ("Triangle - Wide", "სამკუთხედი - ფართო"), + ("Barbed", "წვეტიანი"), + ("Curved", "მრუდი"), + ("Circle", "წრე"), + ("Circle - Solid", "წრე - შევსებული"), + ("Square", "კვადრატი"), + ("Square - Solid", "კვადრატი - შევსებული"), + ("Bar", "ზოლი"), + ("Fit Text", "ტექსტის მორგება"), + ("Preview (W)", "წინასწარი ნახვა (W)"), + ("Normal (W)", "ჩვეულებრივი (W)"), + ("objects", "ობიექტი"), + ("pages", "გვერდი"), + ("stories", "ტექსტური ნაკადი"), + ("items", "ელემენტი"), + ("warnings", "გაფრთხილება"), + ("Render", "რენდერი"), + ("Set", "დაყენება"), + ("unused", "გამოუყენებელი"), + ("⚠ Missing", "⚠ აკლია"), + ("▲ Modified", "▲ შეცვლილია"), + ("(Text Color)", "(ტექსტის ფერი)"), + ("{count} glyphs — click to insert at the text cursor", "გლიფი: {count} — დაწკაპეთ ტექსტის კურსორთან ჩასასმელად"), + ("{pages} Pages in {spreads} Spreads", "გვერდი: {pages}; გაშლა: {spreads}"), + (" (auto)", " (ავტო)"), + ("Absolute Numbering (instead of Section Numbering)", "აბსოლუტური დანომვრა (სექციების დანომვრის ნაცვლად)"), + ( + "Add and remove pages as the primary text frame's story grows and shrinks", + "გვერდების დამატება და წაშლა ძირითადი ტექსტური ჩარჩოს ნაკადის ზრდისა და შემცირებისას", + ), + ("Added Text (highlighted)", "დამატებული ტექსტი (გამოკვეთილი)"), + ("Adjust Layout (objects follow the new page size)", "განლაგების მისადაგება (ობიექტები გვერდის ახალ ზომას მიჰყვება)"), + ("Auto-Fit", "ავტომორგება"), + ("Case sensitive", "რეგისტრის გათვალისწინებით"), + ("Custom Tracking/Kerning", "ტრეკინგის/კერნინგის ხელით მითითება"), + ("Dimensions Include Stroke Weight", "ზომები კონტურის სისქის ჩათვლით"), + ("Enable Autocorrect", "ავტოშესწორების ჩართვა"), + ("Enable Dynamic Spelling (underline misspelled words)", "აკრეფისას მართლწერის შემოწმების ჩართვა (შეცდომით დაწერილი სიტყვების ხაზგასმა)"), + ("Grids in Back", "ბადე უკანა პლანზე"), + ("H&J Violations", "გადატანისა და სწორების დარღვევები"), + ("Include Bleed", "ჭრისპირა არის ჩართვა"), + ("Include Stroke Weight", "კონტურის სისქის გათვალისწინება"), + ("Join our Discord", "შემოგვიერთდით Discord-ზე"), + ("Keep Violations", "სტრიქონების ერთად შენარჩუნების დარღვევები"), + ("Overprint [Black] Swatch at 100%", "[შავი] ნიმუშის ზედდაბეჭდვა 100%-ზე"), + ("Printer's Marks", "საბეჭდი ნიშნები"), + ("Remove Styles and Formatting from Text and Tables", "ტექსტიდან და ცხრილებიდან სტილებისა და ფორმატირების მოცილება"), + ("Ruby", "რუბი"), + ("Show Note Anchors in Layout View", "შენიშვნების ღუზების ჩვენება განლაგების ხედში"), + ("Spreads", "გაშლები"), + ("Substituted Fonts", "ჩანაცვლებული შრიფტები"), + ("Transformations are Totals", "გარდაქმნები ჯამურია"), + ("Use Black Point Compensation", "შავი წერტილის კომპენსაციის გამოყენება"), + ("Use Typographer's Quotes", "ტიპოგრაფიული ბრჭყალების გამოყენება"), + ("Whole word", "მთლიანი სიტყვა"), + ("Interface", "ინტერფეისი"), + ("Advanced Type", "ტექსტის დამატებითი პარამეტრები"), + ("Composition", "კომპოზიცია"), + ("Show Font Names in English", "შრიფტების სახელების ინგლისურად ჩვენება"), + ("Missing Glyphs", "გამოტოვებული გლიფები"), + ("Draw Missing Glyphs from Fallback Fonts", "გამოტოვებული გლიფების სათადარიგო შრიფტებით დახატვა"), + ("Units & Increments", "ერთეულები და ბიჯები"), + ("Grids", "ბადეები"), + ("Guides & Pasteboard", "დამხმარე ხაზები და სამუშაო არე"), + ("Dictionary", "ლექსიკონი"), + ("Autocorrect", "ავტოშესწორება"), + ("Story Editor Display", "ტექსტის რედაქტორის ჩვენება"), + ("File Handling", "ფაილებთან მუშაობა"), + ("Formatting affects container (J)", "ფორმატირება ეხება კონტეინერს (J)"), + ("Formatting affects text (J)", "ფორმატირება ეხება ტექსტს (J)"), + ("Apply None", "არცერთი"), + ("Height Only", "მხოლოდ სიმაღლე"), + ("Width Only", "მხოლოდ სიგანე"), + ("Height and Width", "სიმაღლე და სიგანე"), + ("Height only", "მხოლოდ სიმაღლე"), + ("Width only", "მხოლოდ სიგანე"), + ("Height and width", "სიმაღლე და სიგანე"), + ("Version", "ვერსია"), + ("{count} characters don't fit", "არ ეტევა სიმბოლო: {count}"), + ("document", "დოკუმენტი"), + ("story", "ტექსტური ნაკადი"), + ("selection", "მონიშნული"), + ("top", "ზემოთ"), + ("center", "ცენტრში"), + ("bottom", "ქვემოთ"), + ("justify", "ორმხრივი სწორება"), + ("text", "ტექსტი"), + ("graphic", "გრაფიკა"), + ("unassigned", "მიუნიჭებელი"), + ("Perceptual", "აღქმითი"), + ("Relative Colorimetric", "შეფარდებით კოლორიმეტრული"), + ("Saturation", "გაჯერება"), + ("Absolute Colorimetric", "აბსოლუტური კოლორიმეტრული"), + ("No conflicts", "კონფლიქტები არ არის"), + ("Page (1–{count}):", "გვერდი (1–{count}):"), + ("Fonts in Document: {count} Missing: {missing}", "დოკუმენტის შრიფტები: {count} აკლია: {missing}"), + ("No data source", "მონაცემთა წყარო არ არის"), + ("Select Data Source…", "მონაცემთა წყაროს არჩევა…"), + ("Data Merge", "მონაცემთა შერწყმა"), + ("Preview", "წინასწარი ნახვა"), + ("Previous", "წინა"), + ("Next", "შემდეგი"), + ("Record", "ჩანაწერი"), + ("Merge options", "შერწყმის პარამეტრები"), + ("All records", "ყველა ჩანაწერი"), + ("One record", "ერთი ჩანაწერი"), + ("Range", "დიაპაზონი"), + ("Records per page", "ჩანაწერი გვერდზე"), + ("Single record", "ერთი ჩანაწერი"), + ("Multiple records", "რამდენიმე ჩანაწერი"), + ("Rows first", "ჯერ სტრიქონები"), + ("Columns first", "ჯერ სვეტები"), + ("Column spacing", "სვეტებს შორის დაშორება"), + ("Row spacing", "სტრიქონებს შორის დაშორება"), + ("Fit proportionally", "პროპორციულად მორგება"), + ("Fill proportionally", "პროპორციულად შევსება"), + ("Fit content to frame", "შიგთავსის ჩარჩოზე მორგება"), + ("Center in frame", "ჩარჩოში ცენტრირება"), + ("Link images", "გამოსახულებების დაკავშირება"), + ("Limit", "ზღვარი"), + ("Bind to frame", "ჩარჩოსთან მიბმა"), + ("Insert field", "ველის ჩასმა"), + ("Pass csv, rows, or bytes from the control channel.", "გადაეცით csv, rows ან bytes მართვის არხიდან."), + ("Paragraph Rules…", "აბზაცის ხაზები…"), + ("Paragraph Rules", "აბზაცის ხაზები"), + ("Rule Below", "ხაზი ქვემოთ"), + ("Tint:", "ტონირება:"), + ("Presentation", "პრეზენტაცია"), + ("Column", "სვეტი"), + ("Panels", "პანელები"), + ("Next Page", "შემდეგი გვერდი"), + ("Previous Page", "წინა გვერდი"), + ("First Page", "პირველი გვერდი"), + ("Last Page", "ბოლო გვერდი"), + ("Object Styles", "ობიექტის სტილები"), + ("Row", "სტრიქონი"), + ("Rulers", "სახაზავები"), + ("Hide Guides", "დამხმარე ხაზების დამალვა"), + ("Hide Rulers", "სახაზავების დამალვა"), + ("Extras", "დამატებითი ელემენტები"), + ("Colour", "ფერი"), + ("Transparency", "გამჭვირვალობა"), + ("Community", "საზოგადოება"), + ("Join the community", "შემოუერთდით საზოგადოებას"), + ("OK", "OK"), + ("Done", "მზადაა"), + ("Orientation", "ორიენტაცია"), + ("Units", "ერთეულები"), + ("Number of Pages", "გვერდების რაოდენობა"), + ("Start Page #", "საწყისი გვერდის №"), + ("Slug", "საინფორმაციო ზოლი"), + ("Binding", "აკინძვა"), + ("Rotation", "შემობრუნება"), + ("Flex Layout", "მოქნილი განლაგება"), + ("Auto Size", "ავტომატური ზომა"), + ("Kerning", "კერნინგი"), + ("Alignment", "სწორება"), + ("Indent", "შეწევა"), + ("Space Before", "დაშორება წინ"), + ("Space After", "დაშორება შემდეგ"), + ("New Layer", "ახალი შრე"), + ("Colour Picker", "ფერის ამრჩევი"), + ("Find What", "რა მოიძებნოს"), + ("Change To", "რით ჩანაცვლდეს"), + ("Selection", "მონიშნული"), + ("All Documents", "ყველა დოკუმენტი"), + ("On", "ჩართული"), + ("Anchored Object Options", "მიმაგრებული ობიექტის პარამეტრები"), + ("Apply Parent to Pages", "საბაზო გვერდის გამოყენება გვერდებზე"), + ("ArtCraft App Page", "ArtCraft-ის აპლიკაციის გვერდი"), + ("ArtCraft Website", "ArtCraft-ის ვებსაიტი"), + ("Check Spelling", "მართლწერის შემოწმება"), + ("Corner Options", "კუთხეების პარამეტრები"), + ("Create Guides", "დამხმარე ხაზების შექმნა"), + ("Define Cross-Reference Format", "ჯვარედინი მიმართვის ფორმატის განსაზღვრა"), + ("Define Lists", "სიების განსაზღვრა"), + ("Define Text Variable", "ტექსტური ცვლადის განსაზღვრა"), + ("DesignCraft on GitHub", "DesignCraft GitHub-ზე"), + ("DesignCraft Website", "DesignCraft-ის ვებსაიტი"), + ("Document Endnote Options", "დოკუმენტის ბოლო სქოლიოების პარამეტრები"), + ("Document Footnote Options", "დოკუმენტის სქოლიოების პარამეტრები"), + ("Export EPUB", "EPUB ექსპორტი"), + ("Export EPUB (Fixed Layout)", "EPUB ექსპორტი (ფიქსირებული განლაგება)"), + ("Export EPUB (Reflowable)", "EPUB ექსპორტი (ადაპტური განლაგება)"), + ("Export HTML", "HTML ექსპორტი"), + ("Export IDML", "IDML ექსპორტი"), + ("Export Page as PNG", "გვერდის PNG ფაილად ექსპორტირება"), + ("Export PDF (Interactive)", "PDF ექსპორტი (ინტერაქტიური)"), + ("Export Text", "ტექსტის ექსპორტი"), + ("Export XML", "XML ექსპორტი"), + ("Generate Index", "ინდექსის შექმნა"), + ("Global Light", "გლობალური განათება"), + ("Graphic", "გრაფიკა"), + ("Import XML", "XML იმპორტი"), + ("Insert Anchored Object", "მიმაგრებული ობიექტის ჩასმა"), + ("Insert Cross-Reference", "ჯვარედინი მიმართვის ჩასმა"), + ("Insert Math Expression", "მათემატიკური გამოსახულების ჩასმა"), + ("Insert Pages", "გვერდების ჩასმა"), + ("Join the ArtCraft Discord", "შემოუერთდით ArtCraft-ის Discord-ს"), + ("Load DTD", "DTD-ის ჩატვირთვა"), + ("Load Swatches", "ფერის ნიმუშების ჩატვირთვა"), + ("Margins and Columns", "ველები და სვეტები"), + ("Menus", "მენიუები"), + ("Move Pages", "გვერდების გადატანა"), + ("New Condition", "ახალი პირობა"), + ("New Hyperlink", "ახალი ჰიპერბმული"), + ("New Hyperlink Destination", "ჰიპერბმულის ახალი დანიშნულება"), + ("New Page Reference", "გვერდის ახალი მითითება"), + ("New Parent", "ახალი საბაზო გვერდი"), + ("New Stroke Style", "კონტურის ახალი სტილი"), + ("Numbering & Section Options", "დანომვრისა და სექციის პარამეტრები"), + ("Package", "შეფუთვა"), + ("Place with Import Options", "განთავსება იმპორტის პარამეტრებით"), + ("Print Booklet", "ბროშურის დაბეჭდვა"), + ("Quick Apply", "სწრაფი გამოყენება"), + ("Relink", "ხელახლა დაკავშირება"), + ("Relink to Folder", "საქაღალდეში ხელახლა დაკავშირება"), + ("Report an Issue", "ხარვეზის შეტყობინება"), + ("Save a Copy", "ასლის შენახვა"), + ("Save As", "შენახვა როგორც"), + ("Save Swatches for Exchange", "ფერის ნიმუშების შენახვა გასაცვლელად"), + ("Step and Repeat", "ბიჯით გამეორება"), + ("Table of Contents", "სარჩევი"), + ("Type on a Path Options", "ტრაექტორიაზე ტექსტის პარამეტრები"), + ("Edit/Interface Language", "რედაქტირება/ინტერფეისის ენა"), + ("Edit/Spelling", "რედაქტირება/მართლწერა"), + ("Edit/Transparency Blend Space", "რედაქტირება/გამჭვირვალობის შერევის ფერთა სივრცე"), + ("Edit/Transparency Flattener Presets", "რედაქტირება/გამჭვირვალობის გაბრტყელების პრესეტები"), + ("File/Export", "ფაილი/ექსპორტი"), + ("Layout/Index", "განლაგება/ინდექსი"), + ("Layout/Pages", "განლაგება/გვერდები"), + ("Object/Anchored Object", "ობიექტი/მიმაგრებული ობიექტი"), + ("Object/Arrange", "ობიექტი/დალაგება"), + ("Object/Captions", "ობიექტი/წარწერები"), + ("Object/Clipping Path", "ობიექტი/ჩაჭრის ტრაექტორია"), + ("Object/Content", "ობიექტი/შიგთავსი"), + ("Object/Convert Shape", "ობიექტი/ფიგურის გარდაქმნა"), + ("Object/Effects", "ობიექტი/ეფექტები"), + ("Object/Fitting", "ობიექტი/ზომაზე მორგება"), + ("Object/Pathfinder", "ობიექტი/ტრაექტორიების ოპერაციები"), + ("Object/Paths", "ობიექტი/ტრაექტორიები"), + ("Object/Select", "ობიექტი/მონიშვნა"), + ("Object/Transform", "ობიექტი/გარდაქმნა"), + ("Object/Transform Again", "ობიექტი/გარდაქმნის გამეორება"), + ("Table/Convert Cell Type", "ცხრილი/უჯრის ტიპის გარდაქმნა"), + ("Table/Delete", "ცხრილი/წაშლა"), + ("Table/Insert", "ცხრილი/ჩასმა"), + ("Table/Select", "ცხრილი/მონიშვნა"), + ("Type/Bulleted and Numbered Lists", "ტექსტი/ბურთულებიანი და დანომრილი სიები"), + ("Type/Change Case", "ტექსტი/რეგისტრის შეცვლა"), + ("Type/Hyperlinks & Cross-References", "ტექსტი/ჰიპერბმულები და ჯვარედინი მიმართვები"), + ("Type/Insert Break Character", "ტექსტი/გამყოფი სიმბოლოს ჩასმა"), + ("Type/Insert Special Character/Hyphens and Dashes", "ტექსტი/სპეციალური სიმბოლოს ჩასმა/დეფისები და ტირეები"), + ("Type/Insert Special Character/Markers", "ტექსტი/სპეციალური სიმბოლოს ჩასმა/მარკერები"), + ("Type/Insert Special Character/Other", "ტექსტი/სპეციალური სიმბოლოს ჩასმა/სხვა"), + ("Type/Insert Special Character/Quotation Marks", "ტექსტი/სპეციალური სიმბოლოს ჩასმა/ბრჭყალები"), + ("Type/Insert Special Character/Symbols", "ტექსტი/სპეციალური სიმბოლოს ჩასმა/სიმბოლოები"), + ("Type/Insert White Space", "ტექსტი/ჰარის სიმბოლოს ჩასმა"), + ("Type/Story Direction", "ტექსტი/ტექსტის მიმართულება"), + ("Type/Text Variables", "ტექსტი/ტექსტური ცვლადები"), + ("Type/Track Changes", "ტექსტი/ცვლილებების ჩაწერა"), + ("Type/Type on a Path", "ტექსტი/ტექსტი ტრაექტორიაზე"), + ("View/Display Performance", "ხედი/ჩვენების წარმადობა"), + ("View/Extras", "ხედი/დამატებითი ელემენტები"), + ("View/Grids & Guides", "ხედი/ბადეები და დამხმარე ხაზები"), + ("View/Proof Setup", "ხედი/საცდელი ჩვენების პარამეტრები"), + ("View/Rotate Spread", "ხედი/გაშლის შემობრუნება"), + ("View/Screen Mode", "ხედი/ეკრანის რეჟიმი"), + ("View/Separations Preview", "ხედი/სეპარაციის წინასწარი ნახვა"), + ("View/Structure", "ხედი/სტრუქტურა"), + ("Window/Arrange", "ფანჯარა/დალაგება"), + ("Window/Interactive", "ფანჯარა/ინტერაქტიური"), + ("Window/Interface Color Theme", "ფანჯარა/ინტერფეისის ფერთა თემა"), + ("Window/Output", "ფანჯარა/გამოტანა"), + ("Window/Panels", "ფანჯარა/პანელები"), + ("Window/Type & Tables", "ფანჯარა/ტექსტი და ცხრილები"), + ("Window/Utilities", "ფანჯარა/დამხმარე საშუალებები"), + ("90° CCW", "90° საათის ისრის საწინააღმდეგოდ"), + ("90° CW", "90° საათის ისრის მიმართულებით"), + ("a, b, c, d...", "a, b, c, d..."), + ("A, B, C, D...", "A, B, C, D..."), + ("Alpha Channel", "ალფა-არხი"), + ("Black", "შავი"), + ("Bleed:", "ჭრისპირა არე:"), + ("Bottom:", "ქვემოთ:"), + ("Bullet Character", "ბურთულის სიმბოლო"), + ("Clear Rotation", "შემობრუნების გასუფთავება"), + ("Closed Path", "შეკრული ტრაექტორია"), + ("CMYK:", "CMYK:"), + ("Color Blindness – Deuteranopia", "ფერითი სიბრმავე – დეიტერანოპია"), + ("Color Blindness – Protanopia", "ფერითი სიბრმავე – პროტანოპია"), + ("Column Break", "სვეტის გამყოფი"), + ("Columns:", "სვეტები:"), + ("Conditional Text", "პირობითი ტექსტი"), + ("Copyright Symbol", "საავტორო უფლების სიმბოლო"), + ("Current Page Number", "მიმდინარე გვერდის ნომერი"), + ("Cyan", "ცისფერი"), + ("Dark", "ბნელი"), + ("Delete Type from Path", "ტრაექტორიიდან ტექსტის წაშლა"), + ("Detect Edges", "კიდეების ამოცნობა"), + ("Discretionary Hyphen", "პირობითი დეფისი"), + ("Document CMYK", "დოკუმენტის CMYK"), + ("Document RGB", "დოკუმენტის RGB"), + ("Double Left Quotation Marks", "ორმაგი მარცხენა ბრჭყალი"), + ("Double Right Quotation Marks", "ორმაგი მარჯვენა ბრჭყალი"), + ("Ellipsis", "მრავალწერტილი"), + ("Em Dash", "გრძელი ტირე"), + ("Em Space", "გრძელი შორისი"), + ("En Dash", "მოკლე ტირე"), + ("En Space", "მოკლე შორისი"), + ("Figure Space", "ციფრის შორისი"), + ("Flush Space", "შემავსებელი შორისი"), + ("Forced Line Break", "სტრიქონის იძულებითი წყვეტა"), + ("Frame Break", "ჩარჩოს გამყოფი"), + ("Generate Live Caption", "ცოცხალი წარწერის შექმნა"), + ("GREP", "GREP"), + ("Hair Space", "თმისებრი შორისი"), + ("High Contrast", "მაღალი კონტრასტი"), + ("High Resolution", "მაღალი გარჩევადობა"), + ("Horizontal:", "ჰორიზონტალური:"), + ("i, ii, iii, iv...", "i, ii, iii, iv..."), + ("I, II, III, IV...", "I, II, III, IV..."), + ("Indent to Here", "აქამდე შეწევა"), + ("Ink Limit 300%", "საღებავის ზღვარი 300%"), + ("Internet Standard RGB (sRGB)", "ინტერნეტის სტანდარტული RGB (sRGB)"), + ("Left:", "მარცხნივ:"), + ("Legacy Macintosh RGB", "მოძველებული Macintosh RGB"), + ("Light", "ღია"), + ("Low Resolution", "დაბალი გარჩევადობა"), + ("lowercase", "პატარა ასოები"), + ("Magenta", "მეწამული"), + ("Margins:", "ველები:"), + ("Medium Dark", "საშუალოდ ბნელი"), + ("Medium Light", "საშუალოდ ღია"), + ("Medium Resolution", "საშუალო გარჩევადობა"), + ("Monitor RGB", "მონიტორის RGB"), + ("Next Page Number", "შემდეგი გვერდის ნომერი"), + ("Nonbreaking Hyphen", "უწყვეტი დეფისი"), + ("Nonbreaking Space", "უწყვეტი შორისი"), + ("None (keep transparency)", "არცერთი (გამჭვირვალობის შენარჩუნება)"), + ("Open Path", "ღია ტრაექტორია"), + ("Page Break", "გვერდის გამყოფი"), + ("Paragraph Return", "აბზაცის დასასრული"), + ("Paragraph Symbol", "აბზაცის სიმბოლო"), + ("Picas", "პიკები"), + ("Preflight", "ბეჭდვისწინა შემოწმება"), + ("Previous Page Number", "წინა გვერდის ნომერი"), + ("Punctuation Space", "პუნქტუაციის შორისი"), + ("Quarter Space", "მეოთხედი შორისი"), + ("Registered Trademark Symbol", "რეგისტრირებული სავაჭრო ნიშნის სიმბოლო"), + ("RGB:", "RGB:"), + ("Right Indent Tab", "მარჯვენა შეწევის ტაბულაცია"), + ("Right:", "მარჯვნივ:"), + ("Scripts", "სკრიპტები"), + ("Section Marker", "სექციის მარკერი"), + ("Section Symbol", "სექციის ნიშანი"), + ("Sentence case", "წინადადების რეგისტრი"), + ("Simulate Paper Color", "ქაღალდის ფერის იმიტაცია"), + ("Single Left Quotation Mark", "ერთმაგი მარცხენა ბრჭყალი"), + ("Single Right Quotation Mark", "ერთმაგი მარჯვენა ბრჭყალი"), + ("Sixth Space", "მეექვსედი შორისი"), + ("Slug:", "საინფორმაციო ზოლი:"), + ("Smart Guide Options", "ჭკვიანი დამხმარე ხაზების პარამეტრები"), + ("Story Column Direction", "ტექსტური ნაკადის სვეტების მიმართულება"), + ("Straight Double Quotation Marks", "სწორი ორმაგი ბრჭყალები"), + ("Straight Single Quotation Mark (Apostrophe)", "სწორი ერთმაგი ბრჭყალი (აპოსტროფი)"), + ("Subscript:", "ქვედა ინდექსი:"), + ("Superscript:", "ზედა ინდექსი:"), + ("Tab", "ტაბულაცია"), + ("Thin Space", "ვიწრო შორისი"), + ("Third Space", "მესამედი შორისი"), + ("Title Case", "ყოველი სიტყვა მთავრულით"), + ("Top:", "ზემოთ:"), + ("Trademark Symbol", "სავაჭრო ნიშნის სიმბოლო"), + ("Transform Again Individually", "გარდაქმნის ცალ-ცალკე გამეორება"), + ("Transform Sequence Again", "გარდაქმნების მიმდევრობის გამეორება"), + ("Transform Sequence Again Individually", "გარდაქმნების მიმდევრობის ცალ-ცალკე გამეორება"), + ("Unassigned", "მიუნიჭებელი"), + ("URL:", "URL:"), + ("Vertical:", "ვერტიკალური:"), + ("Working CMYK", "სამუშაო CMYK"), + ("Yellow", "ყვითელი"), + ("Close Panel", "პანელის დახურვა"), + ("Distribute Rows Evenly", "სტრიქონების თანაბრად განაწილება"), + ("Don't Save", "არ შეინახო"), + ("Group with", "ერთად დაჯგუფება"), + ("Pin Bar Position", "ზოლის პოზიციის ჩამაგრება"), + ("Reset Bar Position", "ზოლის პოზიციის ჩამოყრა"), + ("Resize panel window", "პანელის ფანჯრის ზომის შეცვლა"), + ("Resize panels", "პანელების ზომის შეცვლა"), + ("Unsaved Changes", "შეუნახავი ცვლილებები"), + ("“{}” has changes that are not saved. Save them before closing?", "„{}“ შეიცავს შეუნახავ ცვლილებებს. შევინახოთ დახურვამდე?"), + ("Even Page Break", "ლუწი გვერდის გამყოფი"), + ("Odd Page Break", "კენტი გვერდის გამყოფი"), + ("Data Fields", "მონაცემთა ველები"), + ("Data Merge Options", "მონაცემთა შერწყმის პარამეტრები"), + ("Drag Rows or Columns", "სტრიქონების ან სვეტების გადატანა"), + ("Insert Field", "ველის ჩასმა"), + ("List Composite Fonts", "შედგენილი შრიფტების სია"), + ("Preview Record", "ჩანაწერის წინასწარი ნახვა"), + ("Remove Data Source", "მონაცემთა წყაროს მოცილება"), + ("Remove Field", "ველის წაშლა"), + ("Set Composite Font", "შედგენილი შრიფტის დაყენება"), + ("Stop Preview", "წინასწარი ნახვის შეწყვეტა"), + ("Update Data Source", "მონაცემთა წყაროს განახლება"), + ("Validate from Root Element", "ძირეული ელემენტიდან შემოწმება"), +]; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2b13983eb..d1680f91c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -39,7 +39,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "app.language", "Interface Language", None, - "{lang: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk} — menus and panel names (the macOS menu bar follows on the next launch)", + "{lang: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk|ka} — menus and panel names (the macOS menu bar follows on the next launch)", ), ( "app.flattener", @@ -292,6 +292,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.language|Português (Brasil)|{\"lang\": \"pt-br\"}", "ui:app.language|Italiano|{\"lang\": \"it\"}", "ui:app.language|Українська|{\"lang\": \"uk\"}", + "ui:app.language|ქართული|{\"lang\": \"ka\"}", "<", ">Transparency Flattener Presets", "ui:app.flattener|None (keep transparency)|{\"preset\": \"\"}", @@ -2867,6 +2868,29 @@ mod tests { assert_ne!(crate::i18n::tr("it", title), title, "{title}"); } } + + run_ui(&mut app, "app.language", &json!({"lang": "ka"})).unwrap().unwrap(); + assert_eq!(app.ui.language, "ka"); + assert_eq!(checked(&app, "app.language", &json!({"lang": "ka"})), Some(true)); + // Every menu title, item and submenu is translated into Georgian. + let mut untranslated = Vec::new(); + for (title, entries) in menu_tree() { + let mut labels = vec![title.to_string()]; + let mut stack: Vec<&Item> = entries.iter().collect(); + while let Some(entry) = stack.pop() { + match entry { + Item::Sub(label, children) => { + labels.push(label.clone()); + stack.extend(children.iter()); + } + Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()), + _ => {} + } + } + // Labels without letters ("180°") read the same in every language. + untranslated.extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && crate::i18n::tr("ka", l) == l.as_str())); + } + assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}"); } #[test] @@ -2920,7 +2944,7 @@ mod tests { crate::dialogs::confirm(&mut app).unwrap(); frame(&mut app); assert_eq!(app.session.documents().len(), 1); - for lang in ["uk", "zh", "", "ar", "pt-br", "it"] { + for lang in ["uk", "zh", "", "ar", "pt-br", "it", "ka"] { app.run("app.language", json!({"lang": lang})).unwrap(); frame(&mut app); } diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d36053202..9fdcb8bd8 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version; catalogs measured) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Georgian interface) · **Target:** Adobe InDesign 2026 (21.6) Per-language status of the DesignCraft interface and of document typesetting in each script. Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md), @@ -11,7 +11,7 @@ Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-ty ## How it was measured Interface strings live in `crates/ui-egui/src/i18n.rs` (a shared table with German, French, -Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,it,ja,pt_br,uk}.rs`. +Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,it,ja,ka,pt_br,uk}.rs`. Untranslated strings fall back to English. A script (2026-10-10) built the set of English interface strings as the union of every catalog's keys, every engine command label and every literal passed to `tr`/`t` in the UI crate: **1,724 strings**. A further ~145 capitalized literals @@ -49,8 +49,9 @@ string is translated and its script renders and edits correctly; none but Englis |---|---|---|---| | Italian | it | 1,464 (85%) | partial | | Ukrainian | uk | 1,432 (83%), plural categories handled | partial | +| Georgian | ka | 1,724 (~100%: every catalog key plus the command labels the others lack), count captions as `noun: n` | partial (reviewed by a native speaker) | -DesignCraft ships **2** interface languages beyond the twelve; InDesign ships 17 beyond its +DesignCraft ships **3** interface languages beyond the twelve; InDesign ships 17 beyond its eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, tr, uk, zh_TW). ## Document languages @@ -77,4 +78,5 @@ locale shaping. See [typography-parity.md](typography-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Georgian (`ka`) interface added | | 2026-10-10 | major | Created: catalogs measured (1,724 strings), the twelve key languages plus Italian and Ukrainian | From 5d4ed2a663f8cafb4b507eaa6d7ab7be4ec89eb9 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:41:51 +0400 Subject: [PATCH 071/128] text: Georgian document language; no initial capitals for Georgian Georgian is a Properties text language (IDML "Georgian" maps to ka). Georgian has no initial capitals: Mtavruli is only for all-caps text, so Change Case title and sentence case and index section heads leave Mkhedruli as it is (doc::capitalizes); upper case still gives Mtavruli. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/doc/src/attrs.rs | 9 +++++++++ crates/doc/src/index.rs | 15 ++++++++++++++- crates/engine/src/cmd/edit.rs | 16 ++++++++++++++++ crates/engine/src/cmd/text.rs | 6 ++++-- crates/ui-egui/src/panels/properties.rs | 1 + 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 7215c156e..274a79f29 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -190,6 +190,13 @@ impl Digits { } } +/// Does `c` belong to a script that capitalizes the first letter of sentences, words and headings? +/// Georgian doesn't: Unicode maps Mkhedruli to Mtavruli capitals, but those are only for all-caps +/// text, never for a sentence's, a title's or an index heading's first letter. +pub fn capitalizes(c: char) -> bool { + !matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}') +} + /// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese: /// Simplified", or a locale code such as "de_DE_2006"), for the shaper's localized forms (`locl`) /// and everything that depends on the language (quotes, hyphenation and spelling, line breaking, @@ -272,6 +279,7 @@ pub fn language_tag(language: &str) -> Option<&'static str> { "persian" | "farsi" => "fa", "urdu" => "ur", "hebrew" => "he", + "georgian" => "ka", _ => return None, }) } @@ -1004,6 +1012,7 @@ mod tests { assert_eq!(language_tag("English: USA"), Some("en-US")); assert_eq!(language_tag("English: Australian"), Some("en")); assert_eq!(language_tag("Turkish"), Some("tr")); + assert_eq!(language_tag("Georgian"), Some("ka")); assert_eq!(language_tag("Japanese"), Some("ja")); assert_eq!(language_tag("Korean"), Some("ko")); assert_eq!(language_tag("Chinese"), Some("zh")); diff --git a/crates/doc/src/index.rs b/crates/doc/src/index.rs index 0c2e58f4d..c885b7da1 100644 --- a/crates/doc/src/index.rs +++ b/crates/doc/src/index.rs @@ -137,7 +137,12 @@ pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> S let mut heading: Option = None; for (key, n) in &root { if spec.section_headings { - let c = key.chars().next().map(|c| if c.is_alphabetic() { c.to_uppercase().next().unwrap_or(c) } else { '#' }); + // Georgian headings stay Mkhedruli (no capitals; Mtavruli is only for all-caps text). + let c = key.chars().next().map(|c| match c { + c if !c.is_alphabetic() => '#', + c if crate::capitalizes(c) => c.to_uppercase().next().unwrap_or(c), + c => c, + }); if c != heading { heading = c; if let Some(c) = c { @@ -243,4 +248,12 @@ mod tests { let run_in = build(&IndexSpec { run_in: true, section_headings: false, title: String::new(), ..spec }, &refs[..2], &|p| (p + 1).to_string()); assert_eq!(run_in, [("Index Level 1".to_string(), "Typography 2\u{2013}4: kerning 5".to_string())]); } + #[test] + fn georgian_section_heads_stay_mkhedruli() { + let refs = vec![r(&["ასო"], IndexRange::CurrentPage, Some((0, 0))), r(&["ბადე"], IndexRange::CurrentPage, Some((1, 1)))]; + let out = build(&IndexSpec::default(), &refs, &|p| (p + 1).to_string()); + let heads: Vec<&str> = out.iter().filter(|(s, _)| s == "Index Section Head").map(|(_, t)| t.as_str()).collect(); + assert_eq!(heads, ["ა", "ბ"]); + assert!(crate::capitalizes('a') && !crate::capitalizes('ა') && !crate::capitalizes('Ა')); + } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e3c985406..01263478a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -649,6 +649,22 @@ mod text_clipboard_tests { assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len()); st.check().unwrap(); } + + #[test] + fn change_case_leaves_georgian_without_initial_capitals() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ word"); + let len = s.doc().unwrap().doc.story(a).unwrap().len(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); + // Georgian has no title or sentence capitals: Mtavruli is only for all-caps text. + s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი Word"); + s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი word"); + s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ WORD"); + } } fn paste_into(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index add94aca9..d7e8b0c88 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1436,19 +1436,21 @@ pub(crate) fn case_map(src: &str, case: &str) -> Vec { let word_start = !(prev.is_alphanumeric() || matches!(prev, '\'' | '\u{2019}')); let up = |c: char| c.to_uppercase().collect::(); let low = |c: char| c.to_lowercase().collect::(); + // Title and sentence case capitalize only scripts with initial capitals (not Georgian). + let cap = |c: char| if designcraft_doc::capitalizes(c) { up(c) } else { low(c) }; let m = match case { "upper" => up(c), "lower" => low(c), "title" => { if word_start { - up(c) + cap(c) } else { low(c) } } _ => { if sentence_start && c.is_alphanumeric() { - up(c) + cap(c) } else { low(c) } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 358ec76d3..c2d17370f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1257,6 +1257,7 @@ pub(crate) const LANGUAGES: &[&str] = &[ "Greek", "Russian", "Ukrainian", + "Georgian", "Turkish", "Japanese", "Chinese", From 44b83aa6a300c97dc238d5201b04359c7f18b0ae Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 20:55:53 +0400 Subject: [PATCH 072/128] i18n(ka): catalog follows the reviewed translations Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 10fb84d3e..bf0e67201 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -403,6 +403,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Stroke (X)", "კონტური (X)"), ("Apply None (/)", "არცერთი (/)"), ("Default Fill and Stroke", "ნაგულისხმევი შევსება და კონტური"), + ("Formatting Affects Text", "ფორმატირება ეხება ტექსტს"), ("Swap Fill and Stroke", "შევსებისა და კონტურის გაცვლა"), ("Toggle Fill and Stroke", "შევსებისა და კონტურის გადართვა"), ("Reverse the gradient", "გრადიენტის შებრუნება"), @@ -949,7 +950,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "მონიშნეთ განთავსებული ვიდეო ან ხმა (ფაილი › განთავსება: .mp4, .mov, .mp3, .wav …).", ), - ("Select an object to color its fill or stroke.", "მონიშნეთ ობიექტი მისი შევსების ან კონტურის ფერის შესაცვლელად."), + ("Select an object or text to color its fill or stroke.", "მონიშნეთ ობიექტი ან ტექსტი მისი შევსების ან კონტურის ფერის შესაცვლელად."), ("Select an object to give its fill a gradient.", "მონიშნეთ ობიექტი მისი შევსებისთვის გრადიენტის მისაცემად."), ("Select an object to make it a button.", "მონიშნეთ ობიექტი მის ღილაკად გადასაქცევად."), ( @@ -1010,6 +1011,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cap Height", "მთავრული ასოს სიმაღლე"), ("Leading", "სტრიქონთაშორისი ინტერვალი"), ("x Height", "x-სიმაღლე"), + ("Em Box Height", "em-კვადრატის სიმაღლე"), ("Fixed", "ფიქსირებული"), ("Never (continuous)", "არასოდეს (უწყვეტი)"), ("Page", "გვერდი"), @@ -1035,6 +1037,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("forcedLineBreak", "სტრიქონის იძულებითი გადატანა"), ("emSpace", "გრძელი შორისი"), ("enSpace", "მოკლე შორისი"), + ("dropcap", "ინიციალი"), + ("Drop Caps", "ინიციალები"), + ("Lines:", "სტრიქონები:"), + ("Characters:", "სიმბოლოები:"), + ("Drop Lines", "ინიციალის სტრიქონები"), + ("Drop Chars", "ინიციალის სიმბოლოები"), + ("Align Left Edge", "მარცხენა კიდის სწორება"), + ("Scale for Descenders", "მასშტაბირება ქვედა გამონაშვერების მიხედვით"), ("chars", "სიმბოლო"), ("[Automatic]", "[ავტომატური]"), ("Tracking:", "ტრეკინგი:"), From 11b1566c099aff00974010a756bc8204c240a106 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 19:22:54 +0200 Subject: [PATCH 073/128] Type tool: a press over a locked empty frame draws a new text frame instead of converting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adobe's "Set up a modern magazine layout" (Brian Wood, 2:12–4:23) locks a page frame drawn round the spread (Cmd+L), then draws the text frames on top of it with the Type tool. In DesignCraft the press landed in the locked frame, which the Type tool turned into a text frame, putting the caret in it; no new frame was drawn. - The Type tool takes a hit only on text frames (locked or not, as InDesign lets you edit their text) and unlocked empty frames; over a locked empty frame, the press starts a new text frame. - text.placeCaret refuses to convert a locked empty frame (or one on a locked layer). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 9 +++++++-- crates/engine/src/tests.rs | 35 +++++++++++++++++++++++++++++++++++ crates/tools/src/text.rs | 3 +++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..1302df7c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index add94aca9..990a98db7 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -420,8 +420,13 @@ fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { fn place(s: &mut Session, p: &Value, extend: bool) -> Result { let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?; let pt = point_param(p, "point").unwrap_or(Point::ZERO); - // Empty frames become text frames. - let needs_convert = s.doc()?.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned)); + // Empty frames become text frames; a locked one stays as it is. + let st = s.doc()?; + let locked = st.doc.item(frame).is_some_and(|i| i.locked || st.doc.layer(i.layer).is_some_and(|l| l.locked)); + let needs_convert = st.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned)); + if needs_convert && locked { + return Err(bad("text.placeCaret", "the frame is locked")); + } if needs_convert { s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 712dc06bc..a025a5385 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -877,3 +877,38 @@ fn duplicating_a_page_to_an_index_is_one_undo_step() { assert!(s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 4})).is_err()); assert_eq!(widths(&s), [300.0, 400.0, 500.0]); } + +#[test] +fn the_type_tool_draws_a_text_frame_over_a_locked_frame() { + // Adobe's "Set up a modern magazine layout" (4:03): with the page frame locked (Cmd+L), the + // Type tool draws text frames on top of it; the locked frame stays an empty frame. + use designcraft_tools::{PointerEvent, PointerKind, SnapView}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let big = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [big], "swatch": "[Black]", "weight": 6})).unwrap(); + s.execute("object.lock", &json!({"ids": [big]})).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.set_tool("type"); + let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) }; + let layout = s.layout(); + let at = |x: f64, y: f64| layout.to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(x, y)); + let (a, b) = (at(100.0, 100.0), at(300.0, 200.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0 + a.x - 100.0, a.y + 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap(); + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(big)).unwrap(); + assert!(matches!(it.content, designcraft_doc::Content::Unassigned), "the locked frame stays empty"); + let frames: Vec<_> = d.spreads[0].items.iter().filter(|i| i.is_text_frame()).collect(); + assert_eq!(frames.len(), 1, "a new text frame"); + let r = frames[0].bounds(); + assert!((r.x0 - 100.0).abs() < 1e-6 && (r.y1 - 200.0).abs() < 1e-6, "{r:?}"); + // The engine refuses to turn a locked empty frame into a text frame. + assert!(s.execute("text.placeCaret", &json!({"frame": big, "point": [300, 400]})).is_err()); + // Unlocked, a press inside converts it, as before. + s.execute("object.unlockAll", &json!({})).unwrap(); + s.execute("text.placeCaret", &json!({"frame": big, "point": [300, 400]})).unwrap(); + assert!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(big)).unwrap().is_text_frame()); +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index e6ef1a5fe..e2fe043de 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -50,9 +50,12 @@ impl Tool for TypeTool { self.selecting = None; self.cell_drag = None; self.unit_selected = false; + // Text frames take the caret (locked ones too, as in InDesign); an empty frame + // becomes one, unless it's locked: then the press draws a new text frame over it. if let Some((_, id)) = cx.hit(ev.pos) && let Some(it) = cx.doc.item(id) && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) + && (it.is_text_frame() || !it.locked) { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); let clicks = ev.click_count(); From 38fadd1146593106a704a086a825e2daed8033cb Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:34:14 +0200 Subject: [PATCH 074/128] fix: find optical-size fonts named by their WWS family and style (#911) --- crates/fonts/src/fontdb.rs | 78 +++++++++++++++++++++++++----- crates/fonts/src/testing.rs | 24 +++++++++ crates/fonts/src/tests_docfonts.rs | 22 ++++++++- 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index bc9c9e65f..e56a6a36e 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -93,6 +93,9 @@ pub struct FontFace { pub family: String, /// Typographic style name (e.g. "Semibold", "Italic"). pub style: String, + /// The WWS family and style names (name IDs 21 and 22) when they differ from the typographic + /// ones: InDesign names an optical-size face by them ("Kepler Std Display" / "Light"). + pub wws: Option<(String, String)>, /// usWeightClass-style weight (400 = regular). pub weight: f32, pub italic: bool, @@ -338,6 +341,7 @@ impl FontFace { struct CatalogEntry { family: String, style: String, + wws: Option<(String, String)>, path: std::path::PathBuf, /// The font menu group and native family name, read by the scan. group: FontGroup, @@ -350,6 +354,7 @@ struct CatalogEntry { struct ScannedFace { family: String, style: String, + wws: Option<(String, String)>, group: FontGroup, native: Option, } @@ -563,6 +568,31 @@ fn has_face(faces: &[Arc], family: &str, style: &str) -> bool { faces.iter().any(|f| f.family.eq_ignore_ascii_case(family) && f.style.eq_ignore_ascii_case(style)) } +/// A face's WWS family and style names (name IDs 21 and 22), if it has a WWS family and it differs +/// from `family`/`style`. +fn wws_names(f: &skrifa::FontRef<'_>, family: &str, style: &str) -> Option<(String, String)> { + let wf = name(f, &[StringId::WWS_FAMILY_NAME])?; + let ws = name(f, &[StringId::WWS_SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + (!wf.eq_ignore_ascii_case(family) || !ws.eq_ignore_ascii_case(style)).then_some((wf, ws)) +} + +/// The family and style a face is known by: its typographic names, or its WWS names (if it has +/// them) when `by_wws`. +fn known_as<'a>(family: &'a str, style: &'a str, wws: Option<&'a (String, String)>, by_wws: bool) -> Option<(&'a str, &'a str)> { + if by_wws { wws.map(|w| (w.0.as_str(), w.1.as_str())) } else { Some((family, style)) } +} + +/// Is `family` the WWS family name of `f`? +fn has_wws_family(f: &FontFace, family: &str) -> bool { + f.wws.as_ref().is_some_and(|w| w.0.eq_ignore_ascii_case(family)) +} + +/// Is `family` the typographic or the WWS family name of `c`? +#[cfg(not(target_arch = "wasm32"))] +fn in_catalog_family(c: &CatalogEntry, family: &str) -> bool { + c.family.eq_ignore_ascii_case(family) || c.wws.as_ref().is_some_and(|w| w.0.eq_ignore_ascii_case(family)) +} + fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); @@ -628,7 +658,8 @@ fn file_face_names(path: &std::path::Path) -> Vec { crate::group::classify(&skrifa::FontRef::new(&font).ok()?, &family) } }; - Some(ScannedFace { family, style, group, native }) + let wws = wws_names(&f, &family, &style); + Some(ScannedFace { family, style, wws, group, native }) }) .collect() } @@ -740,6 +771,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s let m = f.metrics(Size::unscaled(), &location); let upem = m.units_per_em.max(1) as f64; let a = f.attributes(); + let wws = wws_names(&f, &family, &style); let hb = harfrust::FontRef::from_index(data, index).ok()?; let shaper = harfrust::ShaperData::new(&hb); let instance = (!coords.is_empty()) @@ -754,6 +786,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s id: NEXT_ID.fetch_add(1, Ordering::Relaxed), family, style, + wws, weight, italic, upem, @@ -1054,7 +1087,7 @@ impl FontDb { continue; } for f in file_face_names(&p) { - found.push(CatalogEntry { family: f.family, style: f.style, path: p.clone(), group: f.group, native: f.native }); + found.push(CatalogEntry { family: f.family, style: f.style, wws: f.wws, path: p.clone(), group: f.group, native: f.native }); } } } @@ -1069,7 +1102,7 @@ impl FontDb { fn load_cataloged(&self, family: &str) -> bool { let paths: Vec = { let cat = self.read_catalog(); - let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect(); + let mut p: Vec<_> = cat.iter().filter(|c| in_catalog_family(c, family)).map(|c| c.path.clone()).collect(); p.sort(); p.dedup(); p @@ -1209,7 +1242,7 @@ impl FontDb { } fn is_loaded(&self, family: &str) -> bool { - self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) + self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family) || has_wws_family(f, family)) } /// A shared variable font's axes (see [`ScopedFonts::axes`]). @@ -1409,17 +1442,30 @@ impl ScopedFonts<'_> { } fn exact_style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { + let v = self.named_style_traits(family, false); + // No face of that typographic family: the family may be a WWS name (see `find`). + if v.is_empty() { self.named_style_traits(family, true) } else { v } + } + + /// [`ScopedFonts::exact_style_traits`] by the faces' typographic names, or by their WWS names + /// when `by_wws`. + fn named_style_traits(&self, family: &str, by_wws: bool) -> Vec<(bool, f32, String)> { let mut v: Vec<(bool, f32, String)> = self .own() .iter() .chain(self.db.read_faces().iter()) - .filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{')) - .map(|f| (f.italic, f.weight, f.style.clone())) + .filter(|f| !f.style.contains('{')) + .filter_map(|f| known_as(&f.family, &f.style, f.wws.as_ref(), by_wws).map(|n| (f, n))) + .filter(|(_, (fam, _))| fam.eq_ignore_ascii_case(family)) + .map(|(f, (_, style))| (f.italic, f.weight, style.to_string())) .collect(); #[cfg(not(target_arch = "wasm32"))] for c in self.db.read_catalog().iter() { - if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { - v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); + if let Some((fam, style)) = known_as(&c.family, &c.style, c.wws.as_ref(), by_wws) + && fam.eq_ignore_ascii_case(family) + && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(style)) + { + v.push((style_italic(style), style_weight(style), style.to_string())); } } v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); @@ -1495,11 +1541,11 @@ impl ScopedFonts<'_> { } fn has_exact_family(&self, family: &str) -> bool { - if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family)) || self.db.is_loaded(family) { + if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family) || has_wws_family(f, family)) || self.db.is_loaded(family) { return true; } #[cfg(not(target_arch = "wasm32"))] - if self.db.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) { + if self.db.read_catalog().iter().any(|c| in_catalog_family(c, family)) { return true; } false @@ -1566,12 +1612,20 @@ impl ScopedFonts<'_> { let faces = self.db.read_faces(); // The document's fonts first: they stand in for other fonts of the same name. let named = |f: &&Arc| f.family.eq_ignore_ascii_case(family); - let cands: Vec<&Arc> = self.own().iter().filter(named).chain(faces.iter().filter(named)).collect(); + let mut cands: Vec<&Arc> = self.own().iter().filter(named).chain(faces.iter().filter(named)).collect(); + // No face of that typographic family: the family may be a WWS name, whose faces carry + // their WWS style names. + let by_wws = cands.is_empty(); + if by_wws { + let aliased = |f: &&Arc| has_wws_family(f, family); + cands = self.own().iter().filter(aliased).chain(faces.iter().filter(aliased)).collect(); + } if cands.is_empty() { return None; } + let style_of = |f: &FontFace| -> String { norm(f.wws.as_ref().filter(|_| by_wws).map_or(&f.style, |w| &w.1)) }; let ns = norm(style); - if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { + if let Some(f) = cands.iter().find(|f| style_of(f) == ns) { return Some((*f).clone()); } let (tw, ti) = (style_weight(style), style_italic(style)); diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs index 830f3d358..4ce75acf5 100644 --- a/crates/fonts/src/testing.rs +++ b/crates/fonts/src/testing.rs @@ -67,6 +67,30 @@ pub fn font_mapping(family: &str, glyphs: &[(char, char)]) -> Option> { assemble(base, tables) } +/// [`font_with`] for an optical-size face: typographic names `family` / `style` (name IDs 16 and +/// 17) and WWS names `wws_family` / `wws_style` (IDs 21 and 22), the way InDesign names it +/// ("Kepler Std" / "Light Display" and "Kepler Std Display" / "Light"). `None` if the bundled font +/// can't be read. +pub fn font_with_wws(family: &str, style: &str, wws_family: &str, wws_style: &str, chars: &[char]) -> Option> { + let base = font_with(family, chars)?; + let entries = [(1u16, family), (2, style), (16, family), (17, style), (21, wws_family), (22, wws_style)]; + let count = u16::try_from(entries.len()).ok()?; + let mut name = Vec::new(); + for v in [0u16, count, 6 + count * 12] { + name.extend_from_slice(&v.to_be_bytes()); + } + let mut at = 0usize; + for (id, s) in entries { + let len = s.encode_utf16().count() * 2; + for v in [3u16, 1, 0x409, id, u16::try_from(len).ok()?, u16::try_from(at).ok()?] { + name.extend_from_slice(&v.to_be_bytes()); + } + at += len; + } + entries.iter().for_each(|(_, s)| name.extend(s.encode_utf16().flat_map(u16::to_be_bytes))); + with_tables(&base, vec![(*b"name", name)]) +} + /// Every table of the font file `font`: (tag, bytes). fn tables_of(font: &[u8]) -> Option)>> { let be16 = |b: &[u8], at: usize| b.get(at..at + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes); diff --git a/crates/fonts/src/tests_docfonts.rs b/crates/fonts/src/tests_docfonts.rs index 525bc50b8..f5d84b8a7 100644 --- a/crates/fonts/src/tests_docfonts.rs +++ b/crates/fonts/src/tests_docfonts.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use super::*; -use crate::testing::{font_with, with_fs_type}; +use crate::testing::{font_with, font_with_wws, with_fs_type}; /// An empty temporary folder unique to `name` and this process. fn temp_dir(name: &str) -> PathBuf { @@ -166,3 +166,23 @@ fn restricted_licence_reads_the_embedding_bits() { assert!(!db.face(FALLBACK_FAMILY, "Regular").restricted_licence(), "the bundled fonts are OFL"); assert_eq!(db.face(FALLBACK_FAMILY, "Regular").source, FontSource::Bundled); } + +#[test] +fn optical_size_face_is_found_by_its_wws_names() { + let folder = temp_dir("wws"); + let bytes = font_with_wws("DocFont Kepler", "Light Display", "DocFont Kepler Display", "Light", &['k']).unwrap(); + std::fs::write(folder.join("light-display.otf"), bytes).unwrap(); + let db = FontDb::with_font_dirs(vec![]); + let r = db.load_document_fonts(&folder); + assert_eq!((r.faces, r.skipped.len()), (1, 0), "{:?}", r.skipped); + let fonts = db.scoped(r.scope); + assert!(fonts.has_family("DocFont Kepler Display"), "the WWS family counts as available"); + assert_eq!(fonts.face("DocFont Kepler Display", "Light").style, "Light Display"); + // `font.list` checks the style against these: "Light" isn't a missing style. + assert_eq!(fonts.styles("DocFont Kepler Display"), ["Light"]); + // The typographic names still find it. + assert!(fonts.has_family("DocFont Kepler")); + assert_eq!(fonts.face("DocFont Kepler", "Light Display").style, "Light Display"); + assert_eq!(fonts.styles("DocFont Kepler"), ["Light Display"]); + let _ = std::fs::remove_dir_all(&folder); +} From 149eaf3095bf43040535f44fcbd577152276775a Mon Sep 17 00:00:00 2001 From: Govinda Roy Date: Wed, 14 Oct 2026 19:52:22 +0600 Subject: [PATCH 075/128] Layers panel: double-click an object row to rename it The object's name turns into a field: Enter or a click elsewhere renames it (object.rename), Esc leaves it as it was, and renaming to nothing goes back to the automatic label. Layer rows keep double-click for Layer Options (#416). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/layers.rs | 101 +++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 8f5ef35a4..143be8117 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -230,19 +230,37 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { } let (eye, lock) = eye_lock(ui, &app.ui.language, row, !it.hidden, it.locked, !l.visible, ("item", it.id.0)); let name = item_label(&doc, it); - let clip = Rect::from_min_max(row.min, pos2(row.max.x - 26.0, row.max.y)); - ui.painter().with_clip_rect(clip).text( - row.min + vec2(2.0 * COL_W + 30.0, ROW_H / 2.0), - egui::Align2::LEFT_CENTER, - &name, - egui::FontId::proportional(11.0), - if it.hidden { t.text_dim } else { t.text }, - ); + let field = Rect::from_min_max(row.min + vec2(2.0 * COL_W + 28.0, 3.0), pos2(row.max.x - 28.0, row.max.y - 3.0)); + let renaming = match rename_field(ui, field, it.id.0) { + // An empty name goes back to the automatic label. + Some(Some(new)) => { + if new != it.name { + let _ = app.run("object.rename", json!({"id": it.id.0, "name": new})); + } + false + } + Some(None) => false, + None => ui.data(|d| d.get_temp::<(RenameKey, String)>(egui::Id::new(RENAME))).is_some_and(|(k, _)| k == it.id.0), + }; + if !renaming { + let clip = Rect::from_min_max(row.min, pos2(row.max.x - 26.0, row.max.y)); + ui.painter().with_clip_rect(clip).text( + row.min + vec2(2.0 * COL_W + 30.0, ROW_H / 2.0), + egui::Align2::LEFT_CENTER, + &name, + egui::FontId::proportional(11.0), + if it.hidden { t.text_dim } else { t.text }, + ); + } let sq = selection_square(ui, &app.ui.language, row, lc, is_sel, true, ("item", it.id.0)); if eye { let _ = app.run("object.setFlags", json!({"ids": [it.id.0], "hidden": !it.hidden})); } else if lock { let _ = app.run("object.setFlags", json!({"ids": [it.id.0], "locked": !it.locked})); + } else if resp.double_clicked() { + // Its own name, or the label it shows without the brackets. + let current = if it.name.is_empty() { name.trim_start_matches('<').trim_end_matches('>').to_string() } else { it.name.clone() }; + start_rename(ui, it.id.0, ¤t); } else if sq || resp.clicked() { let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); let _ = app.run("selection.set", json!({"ids": [it.id.0], "add": add})); @@ -273,6 +291,45 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { }); } +/// The object whose row is being renamed. +type RenameKey = u64; +const RENAME: &str = "layers_rename"; + +/// The name field of the row being renamed (double-click), in `r`: `Some(Some(name))` when Enter +/// or a click elsewhere confirms it, `Some(None)` when Esc cancels, `None` while typing or when +/// this row isn't being renamed. +fn rename_field(ui: &mut Ui, r: Rect, key: RenameKey) -> Option> { + let id = egui::Id::new(RENAME); + let (k, mut text): (RenameKey, String) = ui.data(|d| d.get_temp(id))?; + if k != key { + return None; + } + let edit = ui.put(r, egui::TextEdit::singleline(&mut text).font(egui::FontId::proportional(11.0)).margin(vec2(2.0, 1.0))); + if !edit.has_focus() && !edit.lost_focus() { + edit.request_focus(); + } + let done = if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + Some(None) + } else if edit.lost_focus() { + Some(Some(text.trim().to_string())) + } else { + None + }; + ui.data_mut(|d| { + if done.is_some() { + d.remove::<(RenameKey, String)>(id); + } else { + d.insert_temp(id, (key, text)); + } + }); + done +} + +/// Start renaming row `key`, its field showing `name`. +fn start_rename(ui: &Ui, key: RenameKey, name: &str) { + ui.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, name.to_string()))); +} + #[cfg(test)] mod tests { use super::*; @@ -313,4 +370,32 @@ mod tests { h.run_steps(2); assert!(!open(&h)); } + + #[test] + fn rows_rename_in_place() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(260.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); + h.run_steps(2); + let rename = |h: &mut Harness<'_, DesignApp>, key: RenameKey, typed: &str| { + // As a double-click starts it (with an emptied field here, so the typing is the name). + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, String::new()))); + h.run_steps(2); + h.get_by_role(egui::accesskit::Role::TextInput).type_text(typed); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(2); + }; + rename(&mut h, id, "Hero bottle"); + assert_eq!(h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().name, "Hero bottle"); + // Esc leaves the name as it was. + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (id, String::new()))); + h.run_steps(2); + h.get_by_role(egui::accesskit::Role::TextInput).type_text("Oops"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().name, "Hero bottle"); + } } From 524166f562fa112a229d6d03027e4b2b3c728373 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 19:49:04 +0200 Subject: [PATCH 076/128] Margins and Columns and Create Guides laid out as InDesign's Margins sit in Top/Bottom and Inside/Outside pairs (Left/Right on a non-facing document) with a link button between them that makes all four the same; Columns has Number and Gutter. Create Guides puts Rows and Columns side by side, with Fit Guides to Margins/Page and Remove Existing Ruler Guides under Options. Both have a Preview checkbox that previews the change through a session interaction (one undo step on OK, nothing on Cancel), and a measurement field works out arithmetic when it loses focus. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 473 ++++++++++++++++++++++++------- crates/ui-egui/src/i18n.rs | 9 + crates/ui-egui/src/i18n/ar.rs | 9 + crates/ui-egui/src/i18n/it.rs | 9 + crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 9 + crates/ui-egui/src/i18n/uk.rs | 9 + 8 files changed, 427 insertions(+), 97 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..a8959af0f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · Layout › Margins and Columns and Layout › Create Guides are laid out as InDesign's: Margins in Top/Bottom and Inside/Outside pairs with a link button that makes all four the same, Rows and Columns side by side, Fit Guides to Margins or Page, and a Preview checkbox that shows the change on the canvas before OK. A measurement field works out arithmetic when you leave it (`0.4375 in*2` becomes `0.875 in`). No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b6e3e9c5a..1f8fe7f0e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -684,6 +684,257 @@ pub fn open_margins_and_columns(app: &mut DesignApp) { app.ui.dialog = Some(d); } +/// The margin fields of Margins and Columns: (key, label on a facing page, label on a single one). +const MARGIN_FIELDS: [(&str, &str, &str); 4] = [ + ("marginTop", "Top:", "Top:"), + ("marginBottom", "Bottom:", "Bottom:"), + ("marginInside", "Inside:", "Left:"), + ("marginOutside", "Outside:", "Right:"), +]; + +/// Layout › Margins and Columns, laid out as InDesign's: Margins (Top and Bottom, Inside and +/// Outside, with the link button between them: Make all settings the same), Columns (Number, +/// Gutter), Adjust Layout, and Preview beside the options. Measures are in the ruler unit and +/// take arithmetic ("0.4375 in*2"). +fn margins_and_columns_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let facing = app.session.active().is_none_or(|s| s.doc.settings.facing_pages); + // Linked when the dialog opens with four equal margins, as InDesign's. + if !d.fields.contains_key("link") { + let first = d.s("marginTop"); + let same = MARGIN_FIELDS.iter().all(|(k, _, _)| d.s(k) == first); + d.fields.insert("link".into(), json!(same)); + } + link_margins(d); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + group_box(ui, &tr("Margins"), |ui| { + ui.horizontal_top(|ui| { + egui::Grid::new("mac_tb").num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| { + for (k, l, _) in &MARGIN_FIELDS[..2] { + caption_right(ui, &tr(l), 72.0); + measure_field(ui, d, k, 76.0); + ui.end_row(); + } + }); + // The link button, between the two rows. + let linked = d.b("link"); + let tip = tr("Make all settings the same"); + let resp = ui + .vertical(|ui| { + ui.add_space(14.0); + crate::icons::button(ui, if linked { "link" } else { "link-broken" }, 20.0, linked, &tip) + }) + .inner; + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &tip)); + if resp.clicked() { + d.fields.insert("link".into(), json!(!linked)); + } + egui::Grid::new("mac_io").num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| { + for (k, l, single) in &MARGIN_FIELDS[2..] { + caption_right(ui, &tr(if facing { l } else { single }), 72.0); + measure_field(ui, d, k, 76.0); + ui.end_row(); + } + }); + }); + }); + ui.add_space(8.0); + group_box(ui, &tr("Columns"), |ui| { + ui.horizontal(|ui| { + caption_right(ui, &tr("Number:"), 72.0); + text_field(ui, d, "columns", 76.0); + ui.add_space(28.0); + caption_right(ui, &tr("Gutter:"), 72.0); + measure_field(ui, d, "gutter", 76.0); + }); + }); + ui.add_space(6.0); + check(ui, d, "adjustLayout", &tr("Adjust Layout")); + ui.add_space(4.0); + let all_pages = !d.fields.contains_key("parent"); + target_row(app, ui, d, all_pages); + }); + ui.add_space(24.0); + check(ui, d, "preview", &tr("Preview")); + }); + link_margins(d); + layout_dialog_preview(app, d, "layout.marginsAndColumns", "Margins and Columns"); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + +/// Linked margins: a margin changed since the dialog last looked (typed, or set over the control +/// channel) is all four. +fn link_margins(d: &mut Dialog) { + let now: Vec = MARGIN_FIELDS.iter().map(|(k, _, _)| d.s(k)).collect(); + let seen: Vec = d.fields.get("_margins").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_else(|| now.clone()); + let mut now = now; + if d.b("link") + && let Some(i) = now.iter().zip(&seen).position(|(a, b)| a != b) + { + let v = now[i].clone(); + for (k, _, _) in &MARGIN_FIELDS { + d.fields.insert((*k).into(), json!(v)); + } + now = vec![v; MARGIN_FIELDS.len()]; + } + d.fields.insert("_margins".into(), json!(now)); +} + +/// Layout › Create Guides, laid out as InDesign's: Rows and Columns side by side (Number, +/// Gutter), Options (Fit Guides to: Margins or Page, Remove Existing Ruler Guides), and Preview. +fn create_guides_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.horizontal_top(|ui| { + for (title, n, g, id) in [("Rows", "rows", "rowGutter", "cg_rows"), ("Columns", "columns", "columnGutter", "cg_cols")] { + group_box(ui, &tr(title), |ui| { + egui::Grid::new(id).num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| { + caption_right(ui, &tr("Number:"), 72.0); + text_field(ui, d, n, 70.0); + ui.end_row(); + caption_right(ui, &tr("Gutter:"), 72.0); + measure_field(ui, d, g, 70.0); + ui.end_row(); + }); + }); + ui.add_space(8.0); + } + }); + ui.add_space(8.0); + group_box(ui, &tr("Options"), |ui| { + ui.horizontal(|ui| { + ui.label(crate::rtl::widget(ui, tr("Fit Guides to:"))); + let page = d.s("fitTo") == "page"; + if ui.radio(!page, crate::rtl::widget(ui, tr("Margins"))).clicked() { + d.fields.insert("fitTo".into(), json!("margins")); + } + if ui.radio(page, crate::rtl::widget(ui, tr("Page"))).clicked() { + d.fields.insert("fitTo".into(), json!("page")); + } + }); + check(ui, d, "removeExisting", &tr("Remove Existing Ruler Guides")); + }); + ui.add_space(4.0); + target_row(app, ui, d, false); + }); + ui.add_space(24.0); + check(ui, d, "preview", &tr("Preview")); + }); + layout_dialog_preview(app, d, "layout.createGuides", "Create Guides"); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + +/// A measure field: typed arithmetic ("0.4375 in*2") is worked out when the field is left, as +/// InDesign shows it ("click in another field to see the value multiplied by two"). +fn measure_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { + let mut s = d.s(key); + let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(w).horizontal_align(egui::Align::Min)); + if r.changed() { + d.fields.insert(key.into(), Value::String(s)); + } + if r.lost_focus() + && let Some(v) = d.m(key).filter(|v| v.is_finite()) + { + d.fields.insert(key.into(), json!(format_measure(v, d.unit))); + } +} + +/// A titled group, as InDesign's dialogs draw them: the title, then the contents in a thin frame. +fn group_box(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) { + let t = crate::theme::Tokens::get(ui.ctx()); + ui.vertical(|ui| { + crate::rtl::label(ui, egui::RichText::new(title).font(semibold(12.0))); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).inner_margin(egui::Margin::same(8)).corner_radius(2.0).show(ui, add); + }); +} + +/// A field caption (the grid lines the fields up). +fn caption_right(ui: &mut egui::Ui, text: &str, _w: f32) { + ui.add(egui::Label::new(crate::rtl::widget(ui, text)).wrap_mode(egui::TextWrapMode::Extend)); +} + +/// The command params of the Margins and Columns or Create Guides dialog. A measure that does +/// not parse is an error to show, never a silent 0. +fn layout_dialog_params(d: &Dialog) -> Result { + let measure = + |key: &str, what: &str| d.m(key).filter(|x| x.is_finite() && *x >= 0.0).ok_or_else(|| format!("Enter a valid {what}, e.g. 12 or 12 mm.")); + if d.id == "createGuides" { + let (row_gutter, column_gutter) = (measure("rowGutter", "gutter")?, measure("columnGutter", "gutter")?); + let mut p = json!({"rows": d.n("rows").unwrap_or(0.0).max(0.0) as u64, "columns": d.n("columns").unwrap_or(0.0).max(0.0) as u64, + "rowGutter": row_gutter, "columnGutter": column_gutter, "fitTo": d.s("fitTo"), + "removeExisting": d.b("removeExisting"), "spread": d.fields.get("spread").cloned().unwrap_or(json!(0))}); + // Without `page` the command covers every page of the spread. + if d.s("target") != "spread" { + p["page"] = d.fields.get("page").cloned().unwrap_or(json!(0)); + } + return Ok(p); + } + let mut v = Map::new(); + for (k, key) in [("top", "marginTop"), ("bottom", "marginBottom"), ("inside", "marginInside"), ("outside", "marginOutside")] { + v.insert(k.into(), json!(measure(key, &format!("{k} value"))?)); + } + let gutter = measure("gutter", "gutter value")?; + let mut p = + json!({"margins": v, "columns": d.n("columns").unwrap_or(1.0).max(1.0) as u64, "gutter": gutter, "adjustLayout": d.b("adjustLayout")}); + let spread_pages: Vec = + d.fields.get("spreadPages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + if let Some(pi) = d.fields.get("parent").and_then(Value::as_u64) { + p["parent"] = json!(pi); + p["pages"] = + if d.s("target") == "page" { json!([d.fields.get("parentPage").and_then(Value::as_u64).unwrap_or(0)]) } else { json!(spread_pages) }; + } else { + match d.s("target").as_str() { + "all" => {} + "spread" if !spread_pages.is_empty() => p["pages"] = json!(spread_pages), + _ => p["pages"] = json!([d.fields.get("page").and_then(Value::as_u64).unwrap_or(0)]), + } + } + Ok(p) +} + +/// Preview on: the change shows on the page (redone when a field changes) without an undo step; +/// off, the page goes back. +fn layout_dialog_preview(app: &mut DesignApp, d: &mut Dialog, cmd: &str, label: &str) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match layout_dialog_params(d) { + Ok(p) => p, + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction(label) + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview(cmd, params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + /// "Apply to:" choice of the Create Guides and Margins and Columns dialogs, with the page it /// refers to spelled out so a wrong target is visible before the dialog is confirmed. fn target_row(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, all_pages: bool) { @@ -1907,58 +2158,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); }); } - "createGuides" => { - let u = d.unit.suffix(); - egui::Grid::new("cg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (key, label, unit) in [ - ("rows", "Rows: Number", false), - ("rowGutter", "Rows: Gutter", true), - ("columns", "Columns: Number", false), - ("columnGutter", "Columns: Gutter", true), - ] { - let text = crate::i18n::tr(&app.ui.language, label); - ui.label(crate::rtl::widget(ui, if unit { format!("{text} ({u})") } else { text.to_string() })); - text_field(ui, &mut d, key, 80.0); - ui.end_row(); - } - ui.label(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Fit Guides to"))); - combo(ui, &mut d, "fitTo", &[("margins", "Margins"), ("page", "Page")]); - ui.end_row(); - }); - ui.add_space(4.0); - check(ui, &mut d, "removeExisting", crate::i18n::tr(&app.ui.language, "Remove Existing Ruler Guides")); - target_row(app, ui, &mut d, false); - if let Some(st) = d.fields.get("status").and_then(Value::as_str) { - ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); - } - } - "marginsAndColumns" => { - let u = d.unit.suffix(); - egui::Grid::new("mac").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - ui.label(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Columns"))); - text_field(ui, &mut d, "columns", 80.0); - ui.end_row(); - for (key, label) in [ - ("gutter", "Gutter"), - ("marginTop", "Top"), - ("marginBottom", "Bottom"), - ("marginInside", "Inside"), - ("marginOutside", "Outside"), - ] { - // The unit is shown next to every measurement so it is never a guess. - ui.label(crate::rtl::widget(ui, format!("{} ({})", crate::i18n::tr(&app.ui.language, label), u))); - text_field(ui, &mut d, key, 80.0); - ui.end_row(); - } - }); - ui.add_space(4.0); - let all_pages = !d.fields.contains_key("parent"); - target_row(app, ui, &mut d, all_pages); - check(ui, &mut d, "adjustLayout", crate::i18n::tr(&app.ui.language, "Adjust Layout")); - if let Some(st) = d.fields.get("status").and_then(Value::as_str) { - ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); - } - } + "createGuides" => create_guides_dialog(app, ui, &mut d), + "marginsAndColumns" => margins_and_columns_dialog(app, ui, &mut d), "textFrameOptions" => { seed_text_frame_options(app, &mut d); text_frame_options_dialog(app, ui, &mut d); @@ -2044,54 +2245,21 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.cancel_pdf_import(); } match d.id.as_str() { - "createGuides" => { - let (Some(row_gutter), Some(column_gutter)) = - (d.m("rowGutter").filter(|x| *x >= 0.0), d.m("columnGutter").filter(|x| *x >= 0.0)) - else { + "createGuides" | "marginsAndColumns" => { + // The preview goes; the change is made once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let cmd = if d.id == "createGuides" { "layout.createGuides" } else { "layout.marginsAndColumns" }; + let r = layout_dialog_params(&d).and_then(|p| app.run(cmd, p)); + if let Err(e) = &r { + // A value that does not parse, or a refused one, keeps the dialog open to be fixed. let mut d = d.clone(); - d.fields.insert("status".into(), json!("Enter a valid gutter, e.g. 12 or 12 mm.")); + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); app.ui.dialog = Some(d); - return Err("invalid gutter".into()); - }; - let mut p = json!({"rows": d.n("rows").unwrap_or(0.0).max(0.0) as u64, "columns": d.n("columns").unwrap_or(0.0).max(0.0) as u64, - "rowGutter": row_gutter, "columnGutter": column_gutter, "fitTo": d.s("fitTo"), - "removeExisting": d.b("removeExisting"), "spread": d.fields.get("spread").cloned().unwrap_or(json!(0))}); - // Without `page` the command covers every page of the spread. - if d.s("target") != "spread" { - p["page"] = d.fields.get("page").cloned().unwrap_or(json!(0)); } - app.run("layout.createGuides", p) - } - "marginsAndColumns" => { - // A value that does not parse is an error shown in the dialog, never a silent 0. - let mut v = Map::new(); - for (k, key) in [("top", "marginTop"), ("bottom", "marginBottom"), ("inside", "marginInside"), ("outside", "marginOutside"), ("gutter", "gutter")] { - match d.m(key).filter(|x| *x >= 0.0) { - Some(x) => { - v.insert(k.into(), json!(x)); - } - None => { - let mut d = d.clone(); - d.fields.insert("status".into(), json!(format!("Enter a valid {k} value, e.g. 12 or 12 mm."))); - app.ui.dialog = Some(d); - return Err(format!("invalid {k}")); - } - } - } - let gutter = v.remove("gutter").unwrap_or(json!(12.0)); - let mut p = json!({"margins": v, "columns": d.n("columns").unwrap_or(1.0).max(1.0) as u64, "gutter": gutter, "adjustLayout": d.b("adjustLayout")}); - let spread_pages: Vec = d.fields.get("spreadPages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); - if let Some(pi) = d.fields.get("parent").and_then(Value::as_u64) { - p["parent"] = json!(pi); - p["pages"] = if d.s("target") == "page" { json!([d.fields.get("parentPage").and_then(Value::as_u64).unwrap_or(0)]) } else { json!(spread_pages) }; - } else { - match d.s("target").as_str() { - "all" => {} - "spread" if !spread_pages.is_empty() => p["pages"] = json!(spread_pages), - _ => p["pages"] = json!([d.fields.get("page").and_then(Value::as_u64).unwrap_or(0)]), - } - } - app.run("layout.marginsAndColumns", p) + r } "newDocument" => { let margins = json!({"top": d.m("marginTop").unwrap_or(36.0), "bottom": d.m("marginBottom").unwrap_or(36.0), "inside": d.m("marginInside").unwrap_or(36.0), "outside": d.m("marginOutside").unwrap_or(36.0)}); @@ -4615,6 +4783,117 @@ mod tests { assert_eq!(toc_rows(d).iter().map(|r| r.style.as_str()).collect::>(), ["Headline"]); } + #[test] + fn margins_and_columns_link_arithmetic_preview_and_one_undo_step() { + // Adobe's "Set up a modern magazine layout" (1:41–3:04): linked margins to 0.4375 in, then + // 5 columns with a 0.1875 in gutter, unlinked, and "*2" typed after the outside margin. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"preset": "A4", "pages": 3, "facingPages": true, "margins": 36})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0))); + open_margins_and_columns(&mut app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + let mut labels = vec![]; + for _ in 0..3 { + labels = dialog_frame(&mut app, &ctx, screen, vec![]); + } + for expected in + ["Margins and Columns", "Margins", "Top:", "Bottom:", "Inside:", "Outside:", "Columns", "Number:", "Gutter:", "Adjust Layout", "Preview"] + { + visible_label(&labels, screen, expected); + } + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.b("link"), "four equal margins open linked"); + // Linked: one margin typed is all four. + app.ui.dialog.as_mut().unwrap().fields.insert("marginTop".into(), json!("0.4375 in")); + dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(MARGIN_FIELDS.iter().all(|(k, _, _)| d.s(k) == "0.4375 in"), "{:?}", d.fields); + // Preview: the page changes, with no undo step. + let undo = app.session.doc().unwrap().history.undo.len(); + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let top = |app: &DesignApp| app.session.doc().unwrap().doc.spreads[0].pages[0].margins.top; + assert!((top(&app) - 31.5).abs() < 1e-6, "previewed: {}", top(&app)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + // Unlinked (the link button), the outside margin alone: "*2" typed after it is worked out + // when the field is left. + app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let field = labels + .iter() + .filter(|(t, _, _)| t.trim() == "0.4375 in") + .map(|(_, r, _)| *r) + .max_by(|a, b| (a.max.x + a.max.y).total_cmp(&(b.max.x + b.max.y))) + .unwrap(); + click_dialog(&mut app, &ctx, screen, field.center()); + dialog_frame(&mut app, &ctx, screen, vec![key(egui::Key::End), egui::Event::Text("*2".into())]); + dialog_frame(&mut app, &ctx, screen, vec![key(egui::Key::Tab)]); + dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert!( + !d.s("marginOutside").contains('*') && (d.m("marginOutside").unwrap() - 63.0).abs() < 1e-6, + "worked out on leaving the field: {}", + d.s("marginOutside") + ); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("link".into(), json!(false)); + for (k, v) in [("marginOutside", "0.4375 in*2"), ("columns", "5"), ("gutter", "0.1875 in"), ("target", "all")] { + d.fields.insert(k.into(), json!(v)); + } + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + let pg = app.session.doc().unwrap().doc.spreads[1].pages[0].clone(); + assert_eq!((pg.margins.top, pg.margins.outside, pg.columns.count), (31.5, 63.0, 5)); + assert!((pg.columns.gutter - 13.5).abs() < 1e-6); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Cancel after a preview puts the page back. + open_margins_and_columns(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("preview".into(), json!(true)); + d.fields.insert("columns".into(), json!("2")); + dialog_frame(&mut app, &ctx, screen, vec![]); + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc.spreads[1].pages[0].columns.count, 5); + } + + #[test] + fn create_guides_reads_like_indesigns_and_previews() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 1, "facingPages": false})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0))); + open_create_guides(&mut app); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + let mut labels = vec![]; + for _ in 0..3 { + labels = dialog_frame(&mut app, &ctx, screen, vec![]); + } + for expected in + ["Create Guides", "Rows", "Columns", "Options", "Fit Guides to:", "Margins", "Page", "Remove Existing Ruler Guides", "Preview"] + { + visible_label(&labels, screen, expected); + } + // Rows and Columns side by side. + let (r, c) = (visible_label(&labels, screen, "Rows"), visible_label(&labels, screen, "Columns")); + assert!((r.center().y - c.center().y).abs() < 2.0 && r.center().x < c.center().x); + // Thirds, single guides, fitted to the page: clicking the Page radio. + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Page").center()); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.s("fitTo"), "page"); + d.fields.insert("rows".into(), json!("3")); + d.fields.insert("rowGutter".into(), json!("0")); + d.fields.insert("preview".into(), json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let guides = |app: &DesignApp| app.session.doc().unwrap().doc.spreads[0].pages[0].guides.len(); + assert_eq!(guides(&app), 2, "previewed"); + confirm(&mut app).unwrap(); + assert_eq!(guides(&app), 2, "made once"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(guides(&app), 0, "one undo step"); + } + fn dialog_context(language: &str) -> egui::Context { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx, language); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 04ee153c0..ac5422a3c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,15 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Number:", ["Anzahl:", "Nombre :", "Número:", "数:", "数量:"]), + ("Gutter:", ["Steg:", "Gouttière :", "Medianil:", "間隔:", "栏间距:"]), + ("Fit Guides to:", ["Hilfslinien einpassen in:", "Ajuster les repères à :", "Ajustar guías a:", "ガイドの適用先:", "参考线适合:"]), + ("Top:", ["Oben:", "Haut :", "Superior:", "天:", "上:"]), + ("Bottom:", ["Unten:", "Bas :", "Inferior:", "地:", "下:"]), + ("Inside:", ["Innen:", "Intérieur :", "Interior:", "ノド:", "内:"]), + ("Outside:", ["Außen:", "Extérieur :", "Exterior:", "小口:", "外:"]), + ("Left:", ["Links:", "Gauche :", "Izquierda:", "左:", "左:"]), + ("Right:", ["Rechts:", "Droite :", "Derecha:", "右:", "右:"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index f68e7587e..2c24df2ce 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Number:", "العدد:"), + ("Gutter:", "الفاصل:"), + ("Fit Guides to:", "ملاءمة الأدلة مع:"), + ("Top:", "أعلى:"), + ("Bottom:", "أسفل:"), + ("Inside:", "الداخل:"), + ("Outside:", "الخارج:"), + ("Left:", "يسار:"), + ("Right:", "يمين:"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index e91307f18..830dab6e4 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Number:", "Numero:"), + ("Gutter:", "Spaziatura:"), + ("Fit Guides to:", "Adatta guide a:"), + ("Top:", "Superiore:"), + ("Bottom:", "Inferiore:"), + ("Inside:", "Interno:"), + ("Outside:", "Esterno:"), + ("Left:", "Sinistra:"), + ("Right:", "Destra:"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index f45ee094b..5aa0b6b73 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Number:", "数:"), + ("Gutter:", "間隔:"), + ("Fit Guides to:", "ガイドの適用先:"), + ("Inside:", "ノド:"), + ("Outside:", "小口:"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 09e639e5c..185f10f3e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Number:", "Número:"), + ("Gutter:", "Medianiz:"), + ("Fit Guides to:", "Ajustar guias a:"), + ("Top:", "Superior:"), + ("Bottom:", "Inferior:"), + ("Inside:", "Interna:"), + ("Outside:", "Externa:"), + ("Left:", "Esquerda:"), + ("Right:", "Direita:"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index eab4159d0..dfe708d02 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,15 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Number:", "Кількість:"), + ("Gutter:", "Проміжок:"), + ("Fit Guides to:", "Узгодити напрямні з:"), + ("Top:", "Верх:"), + ("Bottom:", "Низ:"), + ("Inside:", "Внутрішнє:"), + ("Outside:", "Зовнішнє:"), + ("Left:", "Ліворуч:"), + ("Right:", "Праворуч:"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), From d3309401f5a5d412023fc245c31caa802b6f74cb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 20:30:21 +0200 Subject: [PATCH 077/128] Show 100% black as rich black on screen by default, as InDesign does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign's Preferences › Appearance of Black defaults to Display All Blacks as Rich Black on screen, so [Black] text and strokes look pure black (RGB 0,0,0), as in Adobe's magazine-layout tutorial. DesignCraft defaulted to Display All Blacks Accurately (#343334). Settings saved with an explicit choice keep it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/lib.rs | 16 ++++++++++++++-- docs/target-app-parity.md | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..1933e6da1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4b5c63f98..6d712d0a8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -214,7 +214,8 @@ pub struct UiState { pub display_quality: designcraft_render::DisplayQuality, /// Preferences › Interface › UI scaling (1 = 100%). pub ui_scale: f32, - /// Preferences › Appearance of Black: 100% K on screen as rich black. + /// Preferences › Appearance of Black: 100% K on screen as rich black (InDesign's default, + /// Display All Blacks as Rich Black; off: accurately, as dark grey). pub rich_black: bool, /// View › Overprint Preview. pub overprint_preview: bool, @@ -334,7 +335,7 @@ impl Default for UiState { ink_limit: None, display_quality: designcraft_render::DisplayQuality::High, ui_scale: 1.0, - rich_black: false, + rich_black: true, overprint_preview: false, shortcuts: Default::default(), control_bar: false, @@ -1414,6 +1415,17 @@ pub fn drop_key_name_text(raw: &mut egui::RawInput, text_field_focused: bool) { mod tests { use super::*; + /// InDesign shows 100% K as pure black on screen unless Appearance of Black says otherwise + /// (Display All Blacks as Rich Black is its default); settings saved without the field get it too. + #[test] + fn black_shows_as_rich_black_by_default() { + assert!(UiState::default().rich_black); + let saved: UiState = serde_json::from_str("{}").unwrap(); + assert!(saved.rich_black); + let accurate: UiState = serde_json::from_str(r#"{"richBlack": false}"#).unwrap(); + assert!(!accurate.rich_black, "a saved choice is kept"); + } + /// An app with one document that has an unsaved edit. fn app_with_unsaved_document() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a97fad6..84fb20b12 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,7 +396,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. - View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen. - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. -- Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. +- Preferences › Appearance of Black: 100% K on screen as rich black (InDesign's default) or accurately as dark grey (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. - Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. On an object-based page it selects objects and shows their pins and width/height locks on the canvas (click to toggle, with tooltips). - Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule, Show Parent Page Overlay); X/Y are shown, but pages don't move within their spread yet. From bfcd6ef71f3a0c83fd43240714658cef13ea23b2 Mon Sep 17 00:00:00 2001 From: zydvc26s67-web Date: Wed, 14 Oct 2026 11:33:35 -0700 Subject: [PATCH 078/128] Style delete: redirect every reference, validate name and replacement delete_para and delete_char previously reassigned only the story text runs/paragraphs using the deleted style, leaving other styles' Based On, paragraph styles' nested/nested-line/GREP style refs, the drop cap style, and the footnote reference style pointed at a name that no longer exists. Extract redirect_style_refs() from rename_style() (everything it does except updating the registry entry's own name) and reuse it from both delete commands, so deleting a style now redirects every reference type to the replacement style before removing the registry entry. Also teach rename_char_refs() about drop_cap_style, which it missed even for renames. Add validation: name must be non-empty and refer to an existing style, and replaceWith (if given) must refer to an existing style and can't be the style being deleted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/engine/src/cmd/style.rs | 72 ++++++++++++++++++++++------------ crates/engine/src/tests.rs | 50 +++++++++++++++++++++++ 2 files changed, 96 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 38eb20d23..7559a8179 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -511,12 +511,29 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { /// Rename a paragraph or character style everywhere it's used. fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { - let st = d.styles_mut(); if para { - for s in &mut st.paragraph { + for s in &mut d.styles_mut().paragraph { if s.name == from { s.name = to.to_string(); } + } + } else { + for s in &mut d.styles_mut().character { + if s.name == from { + s.name = to.to_string(); + } + } + } + redirect_style_refs(d, para, from, to); +} + +/// Point every reference to style `from` at `to`, except the style's own registry entry (its +/// `name` field) — shared by renaming (which also updates the entry's name) and deletion (which +/// instead removes the entry, after its former uses point at the replacement). +fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { + let st = d.styles_mut(); + if para { + for s in &mut st.paragraph { for r in [&mut s.based_on, &mut s.next_style] { if r.as_deref() == Some(from) { *r = Some(to.to_string()); @@ -530,9 +547,6 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: & } } else { for s in &mut st.character { - if s.name == from { - s.name = to.to_string(); - } if s.based_on.as_deref() == Some(from) { s.based_on = Some(to.to_string()); } @@ -564,7 +578,8 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: & } } -/// Point the nested, nested line and GREP styles of paragraph attributes at a renamed character style. +/// Point the nested, nested line and GREP styles, and the drop cap style, of paragraph +/// attributes at a renamed (or deleted, pointed at its replacement) character style. fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) { let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style); let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style)); @@ -573,6 +588,9 @@ fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) { *n = to.to_string(); } } + if p.drop_cap_style.as_deref() == Some(from) { + p.drop_cap_style = Some(to.to_string()); + } } fn group_styles(s: &mut Session, p: &Value) -> Result { @@ -610,22 +628,23 @@ fn group_styles(s: &mut Session, p: &Value) -> Result { } fn delete_para(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").unwrap_or("").to_string(); + let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad("style.paragraph.delete", "missing name"))?.to_string(); let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::BASIC_PARAGRAPH).to_string(); if name.starts_with('[') { return Err(bad("style.paragraph.delete", "built-in styles can't be deleted")); } + if repl == name { + return Err(bad("style.paragraph.delete", "`replaceWith` can't be the deleted style")); + } s.edit(|d, _| { - d.styles_mut().paragraph.retain(|x| x.name != name); - for sid in d.stories.keys().copied().collect::>() { - if let Some(story) = d.story_mut(sid) { - for f in &mut story.paras { - if f.style == name { - f.style = repl.clone(); - } - } - } + if d.styles.para(&name).is_none() { + return Err(bad("style.paragraph.delete", format!("no paragraph style `{name}`"))); } + if d.styles.para(&repl).is_none() { + return Err(bad("style.paragraph.delete", format!("no paragraph style `{repl}`"))); + } + redirect_style_refs(d, true, &name, &repl); + d.styles_mut().paragraph.retain(|x| x.name != name); ok() }) } @@ -718,22 +737,23 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { } fn delete_char(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").unwrap_or("").to_string(); + let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad("style.character.delete", "missing name"))?.to_string(); let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string(); if name.starts_with('[') { return Err(bad("style.character.delete", "built-in styles can't be deleted")); } + if repl == name { + return Err(bad("style.character.delete", "`replaceWith` can't be the deleted style")); + } s.edit(|d, _| { - d.styles_mut().character.retain(|x| x.name != name); - for sid in d.stories.keys().copied().collect::>() { - if let Some(story) = d.story_mut(sid) { - story.for_each_text_mut(&mut |st| { - for r in st.chars.iter_mut().filter(|r| r.format.style == name) { - r.format.style = repl.clone(); - } - }); - } + if d.styles.char_style(&name).is_none() { + return Err(bad("style.character.delete", format!("no character style `{name}`"))); } + if d.styles.char_style(&repl).is_none() { + return Err(bad("style.character.delete", format!("no character style `{repl}`"))); + } + redirect_style_refs(d, false, &name, &repl); + d.styles_mut().character.retain(|x| x.name != name); ok() }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e47542518..d7dc80b68 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -681,6 +681,56 @@ fn character_style_delete_rejects_builtin_styles() { assert!(r.is_err(), "built-in styles can't be deleted"); } +#[test] +fn character_style_delete_rejects_missing_unknown_and_self_replacement() { + let mut s = session(); + let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + assert!(s.execute("style.character.delete", &json!({"name": ""})).is_err(), "empty name is rejected"); + assert!(s.execute("style.character.delete", &json!({"name": "No Such Style"})).is_err(), "unknown name is rejected"); + assert!(s.execute("style.character.delete", &json!({"name": name, "replaceWith": "No Such Style"})).is_err(), "unknown replaceWith is rejected"); + assert!(s.execute("style.character.delete", &json!({"name": name, "replaceWith": name})).is_err(), "replaceWith can't be the deleted style"); +} + +#[test] +fn paragraph_style_delete_rejects_missing_unknown_and_self_replacement() { + let mut s = session(); + let name = s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string(); + assert!(s.execute("style.paragraph.delete", &json!({"name": ""})).is_err(), "empty name is rejected"); + assert!(s.execute("style.paragraph.delete", &json!({"name": "No Such Style"})).is_err(), "unknown name is rejected"); + assert!(s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": "No Such Style"})).is_err(), "unknown replaceWith is rejected"); + assert!(s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": name})).is_err(), "replaceWith can't be the deleted style"); +} + +#[test] +fn character_style_delete_reassigns_every_kind_of_reference() { + let mut s = session(); + let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + let repl = s.execute("style.character.create", &json!({"name": "Emphasis"})).unwrap()["name"].as_str().unwrap().to_string(); + // Another character style's basedOn. + let based = s.execute("style.character.create", &json!({"name": "Strong", "basedOn": name})).unwrap()["name"].as_str().unwrap().to_string(); + // A paragraph style's nested, nested line, GREP and drop cap styles, and the footnote reference style. + s.execute( + "style.paragraph.create", + &json!({"name": "Body", "para": { + "nestedStyles": [{"style": name, "through": true, "count": 1, "until": {"kind": "words"}}], + "grepStyles": [{"style": name, "pattern": "x"}], + "nestedLineStyles": [{"style": name, "lines": 1}], + "dropCapStyle": name}}), + ) + .unwrap(); + s.execute("footnote.options", &json!({"refCharStyle": name})).unwrap(); + s.execute("style.character.delete", &json!({"name": name, "replaceWith": repl})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.styles.char_style(&name).is_none(), "deleted style is gone"); + assert_eq!(st.doc.styles.char_style(&based).unwrap().based_on.as_deref(), Some(repl.as_str()), "basedOn redirected"); + let body = st.doc.styles.para("Body").unwrap(); + assert_eq!(body.para.nested_styles.as_ref().unwrap()[0].style, repl, "nested style redirected"); + assert_eq!(body.para.nested_line_styles.as_ref().unwrap()[0].style, repl, "nested line style redirected"); + assert_eq!(body.para.grep_styles.as_ref().unwrap()[0].style, repl, "grep style redirected"); + assert_eq!(body.para.drop_cap_style.as_deref(), Some(repl.as_str()), "drop cap style redirected"); + assert_eq!(st.doc.footnote_options.ref_char_style, repl, "footnote reference style redirected"); +} + #[test] fn page_tool_selects_a_page_and_its_handles_preview_or_resize_it() { use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind}; From 4516989e51b7f688e3f7e7dbff4a1ba0e354109a Mon Sep 17 00:00:00 2001 From: zydvc26s67-web Date: Wed, 14 Oct 2026 12:05:35 -0700 Subject: [PATCH 079/128] Type: let a blank line's hidden paragraph mark take leading immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting an empty paragraph (a blank line) puts a collapsed caret on its hidden paragraph mark, the only character on that line. Previously type.char either deferred the formatting to the next typed character (in a non-empty story) or dropped it on the floor for composition, so there was no way to change a blank line's height — matching InDesign's ability to select the pilcrow and adjust leading. - engine/cmd/text.rs: recognize an empty paragraph's own caret position (empty_para_mark) and apply character attributes straight to that byte's run instead of deferring to typing format. - compose/lib.rs: an empty paragraph has no glyphs of its own, so resolve the hidden mark's run (if overridden) for the paragraph's base leading instead of always falling back to the paragraph's plain default. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/compose/src/lib.rs | 14 ++++++-- crates/engine/src/cmd/text.rs | 61 ++++++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c6312e9f5..a62ce4c30 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -688,9 +688,19 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C &[], (drop_end, &pp.drop_cap_style), ); + // An empty paragraph has no glyphs of its own, so its only addressable character is its + // hidden paragraph mark (the `\n` just past `prange.end`). A leading/size override applied + // there (e.g. selecting the pilcrow to adjust line height) still needs to set the line's + // height, so resolve the mark's own run instead of the paragraph's plain default when present. + let mark_chars = prange + .is_empty() + .then(|| story.runs().find(|(r, _)| r.contains(&prange.end))) + .flatten() + .map(|(_, fmt)| doc.styles.resolve_char(&base_chars, fmt)); + let leading_chars = mark_chars.as_ref().unwrap_or(&base_chars); // Drop cap: the size and indent come from the text beside it (`base_*` when there's none). - let base_leading = match base_chars.leading { - designcraft_doc::Leading::Auto => base_chars.size * pp.auto_leading, + let base_leading = match leading_chars.leading { + designcraft_doc::Leading::Auto => leading_chars.size * pp.auto_leading, designcraft_doc::Leading::Points(v) => v, }; let base_cap = { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index add94aca9..d07c1cd4c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1031,6 +1031,14 @@ pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &C Ok(()) } +/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that +/// line, and so the one a line-affecting attribute like leading can land on right away instead of +/// waiting for the next typed character (otherwise an empty line could never get its own leading). +fn empty_para_mark(story: &designcraft_doc::Story, pos: usize) -> bool { + let ranges = story.para_ranges(); + ranges.get(story.para_at(pos)).is_some_and(|r| r.start == pos && r.end == pos) && story.text.as_bytes().get(pos) == Some(&b'\n') +} + pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { let mut a = CharAttrs::default(); if let Some(o) = attrs.as_object() { @@ -1041,11 +1049,13 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { validate_char_fill(&s.doc()?.doc, &a, "type.char")?; let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); let targets = format_targets(s); - // A caret in text: the format is for the next typing. + // A caret in text: the format is for the next typing — unless it sits on an empty paragraph's + // hidden mark, which takes the format immediately (there is nothing else on that line to type + // into first). if let [t] = targets.as_slice() && t.range.is_empty() && let Some(at) = s.doc()?.selection.text - && s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty()) + && s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty() && !empty_para_mark(st, t.range.start)) { let doc = s.doc()?.uid; let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] }); @@ -1067,12 +1077,21 @@ pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Tar let r = &t.range; let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; if r.is_empty() { - // An empty story: its one (empty) run is the format typing takes. - if st.is_empty() - && let Some(run) = st.chars.first_mut() - { - run.format.over.merge(a); - st.rev += 1; + if st.is_empty() { + // An empty story: its one (empty) run is the format typing takes. + if let Some(run) = st.chars.first_mut() { + run.format.over.merge(a); + st.rev += 1; + } + } else if empty_para_mark(st, r.start) { + // An empty paragraph elsewhere in the story: its hidden paragraph mark is its only + // character, and the one that carries line-affecting attributes like leading. + st.format_chars(r.start..r.start + 1, |f| { + f.over.merge(a); + for k in cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); } continue; } @@ -1639,6 +1658,32 @@ What's new in typography this season, and why it matters to every designer"}), s.execute("type.openType", &json!({"figures": "proportionalOldstyle"})).unwrap(); assert_ne!(gids(&s), lining); } + + /// A blank line (an empty paragraph) has no characters to select, but its hidden paragraph + /// mark can still take a leading change right away: otherwise there'd be no way to adjust the + /// line height of a blank line at all. + #[test] + fn leading_applies_to_an_empty_lines_hidden_paragraph_mark() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Para one\n\nPara two"; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + // The blank line between the two paragraphs: a collapsed caret, since it has no text. + let blank = text.find("\n\n").unwrap() + 1; + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + s.execute("type.char", &json!({"attrs": {"leading": {"kind": "points", "value": 40.0}}})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap(); + // `char_format_at` resolves a boundary to the *preceding* run (new typing's format), so + // check just past the mark's own one-byte run, which the mark alone occupies. + assert_eq!(st.char_format_at(blank + 1).over.leading, Some(designcraft_doc::Leading::Points(40.0))); + // No pending typing format was left behind: it applied immediately. + assert!(s.typing_format.is_none()); + let d = s.doc().unwrap().doc.clone(); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + let blank_line = cs.frames[0].lines.iter().find(|l| l.range == (blank..blank)).unwrap(); + assert_eq!(blank_line.leading, 40.0, "the blank line's own height changed"); + } } #[cfg(test)] From 8b834d053383d6a3ac95f5a5ef240755f2d28877 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:17:09 +0000 Subject: [PATCH 080/128] Fix overflow in layout.pages.insert when `after` is u64::MAX Found while testing crates/doc/src/build.rs:377 (Document::insert_pages), called from crates/engine/src/cmd/layout.rs:91 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/build.rs | 2 +- crates/engine/src/cmd/layout.rs | 2 +- crates/engine/tests/insert_pages_bounds.rs | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/insert_pages_bounds.rs diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 574049d76..ea4dc5cd5 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -374,7 +374,7 @@ impl Document { return Ok(vec![]); } let n = self.page_count(); - let at = after.map_or(0, |a| (a + 1).min(n)); + let at = after.map_or(0, |a| a.saturating_add(1).min(n)); let template = self.page(at.saturating_sub(1).min(n.saturating_sub(1))).cloned(); let (cols, gutter, margins) = template.as_ref().map(|t| (t.columns.count, t.columns.gutter, t.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0))); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 6d6e06ee1..9ae584fe1 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -88,7 +88,7 @@ pub fn specs() -> Vec { let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize; let st = s.doc()?; let n = st.doc.page_count(); - let after = p.get("after").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(n - 1); + let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1)); let parent = parent_ref(&st.doc, p.get("parent")).unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); s.edit(|d, _| { let ids = d.insert_pages(Some(after), count, parent)?; diff --git a/crates/engine/tests/insert_pages_bounds.rs b/crates/engine/tests/insert_pages_bounds.rs new file mode 100644 index 000000000..e96a6cc4b --- /dev/null +++ b/crates/engine/tests/insert_pages_bounds.rs @@ -0,0 +1,16 @@ +//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing. +use designcraft_engine::Session; +use serde_json::json; + +#[test] +fn insert_after_u64_max_appends_at_the_end() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages":2})).unwrap(); + let before: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); + let out = s.execute("layout.pages.insert", &json!({"after": u64::MAX, "count": 2})).unwrap(); + assert_eq!(out["total"], 4); + let after: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); + assert_eq!(after.len(), 4); + assert_eq!(after[..2], before[..], "the original pages must stay first; new pages go at the end"); + assert!(after[2..].iter().all(|id| !before.contains(id))); +} From d5d86217808234adfc69a0e238ded4d1116559cb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 21:22:17 +0200 Subject: [PATCH 081/128] Pathfinder keeps the frontmost object, as InDesign does InDesign's Pathfinder result takes the attributes (and content) of the frontmost object, except Subtract, which keeps the backmost. DesignCraft kept the backmost for every op but Minus Back, so a photo placed over strips and intersected with them was deleted and the black strips stayed (Zero2Graphic's magazine tutorial, 7:19). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/paths.rs | 55 ++++++++++++++++++++++++++++++++-- docs/target-app-parity.md | 2 +- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..4b25d802c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..9cfefdb4c 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { "Pathfinder", ["Object", "Pathfinder"], None, - "{op: add|subtract|intersect|exclude|minusBack, ids?} — combine the selected shapes into one (the backmost keeps its look; the frontmost for minusBack) → {id}", + "{op: add|subtract|intersect|exclude|minusBack, ids?} — combine the selected shapes into one, which keeps the frontmost object's look and content (a placed graphic stays in it); Subtract keeps the backmost → {id}", has_selection, pathfinder ), @@ -145,7 +145,9 @@ fn pathfinder(s: &mut Session, p: &Value) -> Result { if order.len() < 2 { return Err(bad("object.pathfinder", "select two or more shapes on one spread (not text frames or groups)")); } - let keep = if op == Op::MinusBack { order[order.len() - 1] } else { order[0] }; + // InDesign: the result takes the frontmost object's look and content (a photo placed over + // shapes shows through their intersection); Subtract keeps the backmost, the front cuts it. + let keep = if op == Op::Subtract { order[0] } else { order[order.len() - 1] }; let shapes: Vec = order.iter().filter_map(|id| spread_xf(d, *id).and_then(|(_, xf)| Some(d.item(*id)?.path.transformed(xf)))).collect(); let Some(result) = combine(op, &shapes) else { return Err(bad("object.pathfinder", "the shapes don't overlap: nothing is left")) }; @@ -417,6 +419,55 @@ mod tests { assert!(s.execute("object.pathfinder", &json!({"op": "nope"})).is_err()); } + /// The result keeps the frontmost object's look (Subtract: the backmost's), as InDesign's. + #[test] + fn pathfinder_keeps_the_frontmost_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("swatch.create", &json!({"name": "Red", "color": [255, 0, 0]})).unwrap(); + for op in ["add", "intersect", "exclude", "minusBack"] { + let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 100, 400, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap(); + s.execute("object.fill", &json!({"ids": [b], "swatch": "Red"})).unwrap(); + let r = s.execute("object.pathfinder", &json!({"op": op, "ids": [a, b]})).unwrap(); + assert_eq!(r["id"], b, "{op}: the front object stays"); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ItemId(a)).is_none(), "{op}"); + assert_eq!(d.item(ItemId(b)).unwrap().fill.swatch, "Red", "{op}"); + s.execute("edit.clear", &json!({"ids": [b]})).unwrap(); + } + } + + /// A photo placed over strips and intersected with them shows through the strips (it was + /// dropped: the black strips were kept). + #[test] + fn pathfinder_intersect_keeps_a_placed_photo_in_front() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 140]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [100, 160, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [a, b], "swatch": "[Black]"})).unwrap(); + let strips = s.execute("object.pathfinder", &json!({"op": "add", "ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 20, height: 20, pixels: [255u8, 255, 255, 255].repeat(400) }.to_png(); + let photo = s.execute("frame.create", &json!({"rect": [80, 80, 320, 220], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "white.png", "frame": photo})).unwrap(); + s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [photo]})).unwrap(); + let r = s.execute("object.pathfinder", &json!({"op": "intersect", "ids": [strips, photo]})).unwrap(); + assert_eq!(r["id"], photo); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ItemId(strips)).is_none()); + let it = d.item(ItemId(photo)).unwrap(); + assert!(matches!(it.content, Content::Graphic(_)), "the photo stays in the result"); + assert_eq!(it.path.subpaths.len(), 2, "cut to the two strips"); + let b = it.bounds(); + assert!((b.x0 - 100.0).abs() < 0.5 && (b.y0 - 100.0).abs() < 0.5 && (b.x1 - 300.0).abs() < 0.5 && (b.y1 - 200.0).abs() < 0.5, "{b:?}"); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + assert!(img.pixel(200, 120)[0] > 230, "the photo (white) shows in a strip: {:?}", img.pixel(200, 120)); + } + #[test] fn gradient_feather_fades_on_screen_and_in_pdf() { let mut s = Session::new(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a97fad6..a589ac3e6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -416,7 +416,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). - Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read. - Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. -- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. +- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); the result keeps the frontmost object's look and content, so a photo intersected with shapes behind it shows through them (Subtract keeps the backmost); `object.pathfinder`. - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). - Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. From bb96f1455f20c52bfd4fe0ec0910a642d25a7cd1 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 21:32:57 +0200 Subject: [PATCH 082/128] Fill with Placeholder Text in paragraphs, as InDesign does InDesign's placeholder text comes in paragraphs; Zero2Graphic's magazine tutorial styles its second paragraph as an 18 pt lead (2:16). DesignCraft filled the frame with one paragraph, and the last filler sentence ran into the first ("interit.Ovid"). Paragraphs now hold 2 to 5 sentences, and an overset fill is trimmed to the last whole sentence. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 45 +++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..aadbad502 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · Type › Fill with Placeholder Text fills a frame with paragraphs of a few sentences, as InDesign does, so one paragraph can be styled on its own (it was one long paragraph, and the filler ran sentences together, "interit.Ovid"). No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index add94aca9..f6ed4eca5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1398,20 +1398,30 @@ fn fill_placeholder(s: &mut Session, p: &Value) -> Result { story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))? } }; - // Fill until overset: repeat the filler sentences. + // Fill until overset with paragraphs of a few sentences each, as InDesign's placeholder text + // comes (so a paragraph can be styled on its own). + const SENTENCES_PER_PARAGRAPH: [usize; 6] = [3, 4, 2, 5, 3, 4]; let mut text = String::new(); - let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").collect(); + let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").map(str::trim_end).collect(); let doc = s.doc()?.doc.clone(); let mut d2 = (*doc).clone(); + let (mut paragraph, mut in_paragraph) = (0, 0); for i in 0..400 { text.push_str(sentences[i % sentences.len()]); + in_paragraph += 1; + if in_paragraph >= SENTENCES_PER_PARAGRAPH[paragraph % SENTENCES_PER_PARAGRAPH.len()] { + text.push('\n'); + (paragraph, in_paragraph) = (paragraph + 1, 0); + } else { + text.push(' '); + } if i % 3 == 2 { d2.set_story_text(sid, &text)?; let cs = compose::compose_story(&d2, sid, &compose::ComposeOptions::default()); if cs.is_overset() { // Trim to the last sentence that fits. - let cut = cs.overset_at.unwrap_or(text.len()); - let keep = text[..cut.min(text.len())].rfind(". ").map(|k| k + 1).unwrap_or(cut); + let cut = cs.overset_at.unwrap_or(text.len()).min(text.len()); + let keep = text.get(..cut).and_then(|t| t.rfind('.')).map(|k| k + 1).unwrap_or(cut); text.truncate(keep); break; } @@ -2559,3 +2569,30 @@ mod sentence_selection_tests { assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); } } + +#[cfg(test)] +mod placeholder_tests { + use serde_json::json; + + use crate::Session; + + /// Fill with Placeholder Text gives paragraphs of a few sentences, as InDesign's does (it was one + /// paragraph, and the filler ran sentences together: "interit.Ovid"). + #[test] + fn placeholder_text_comes_in_paragraphs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 336, 736], "content": "text", "text": ""})).unwrap(); + s.execute("type.fillWithPlaceholder", &json!({"frame": r["id"]})).unwrap(); + let st = s.execute("story.get", &json!({"story": r["story"]})).unwrap(); + let text = st["text"].as_str().unwrap(); + let paragraphs: Vec<&str> = text.split('\n').collect(); + assert!(paragraphs.len() >= 4, "{} paragraphs", paragraphs.len()); + for p in ¶graphs { + assert!(p.ends_with('.') && !p.starts_with(' '), "a paragraph of whole sentences: {p:?}"); + } + assert!(!text.contains(".O") && !text.contains(".L"), "sentences are spaced: {text}"); + assert!(st["overset"].is_null(), "nothing overset: {}", st["overset"]); + assert!(text.len() > 1500, "the frame is filled: {}", text.len()); + } +} From 2302b7ec7f01d6301365383275b2001a2f7a3714 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:42:32 +0000 Subject: [PATCH 083/128] Keep viewBox-only placed SVGs at viewBox size on IDML import Fixes #950 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/idml/src/import.rs | 44 ++++++++++++++++++++++++++++++--- crates/idml/src/tests/assets.rs | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..d2c6dd534 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -242,6 +242,40 @@ fn nums(s: &str) -> Vec { s.split_whitespace().filter_map(|v| v.parse().ok()).collect() } +/// The viewBox size of an SVG whose root has a viewBox but no width/height (percentages count as +/// none): InDesign places such a file at one viewBox unit to the point. +fn svg_viewbox_size(data: &[u8]) -> Option<(f64, f64)> { + use quick_xml::events::Event; + if data.iter().find(|b| !b.is_ascii_whitespace()) != Some(&b'<') { + return None; + } + let mut r = quick_xml::Reader::from_reader(data); + let mut buf = Vec::new(); + let root = loop { + match r.read_event_into(&mut buf).ok()? { + Event::Start(e) | Event::Empty(e) => break e.into_owned(), + Event::Eof => return None, + _ => buf.clear(), + } + }; + if root.local_name().as_ref() != b"svg" { + return None; + } + let attr = |name: &[u8]| { + root.attributes().with_checks(false).flatten().find(|a| a.key.as_ref() == name).and_then(|a| a.unescape_value().ok().map(|v| v.to_string())) + }; + let absolute = |name: &[u8]| attr(name).is_some_and(|v| !v.trim().ends_with('%')); + if absolute(b"width") || absolute(b"height") { + return None; + } + let vb: Vec = + attr(b"viewBox")?.split(|c: char| c.is_whitespace() || c == ',').filter(|v| !v.is_empty()).filter_map(|v| v.parse().ok()).collect(); + match vb[..] { + [_, _, w, h] if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => Some((w, h)), + _ => None, + } +} + fn parse_affine(s: Option<&str>) -> Affine { match s.map(nums) { Some(v) if v.len() == 6 => Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]), @@ -2366,7 +2400,7 @@ impl<'r> Importer<'r> { let size = ((r - l).abs(), (b - t).abs()); let link = g.find("Link"); let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path); - let graphic = |asset| { + let graphic = |asset, size| { Content::Graphic(designcraft_doc::Graphic { asset, size, @@ -2383,13 +2417,17 @@ impl<'r> Importer<'r> { { // Every placement of a linked file shares one asset; read the file once. if let Some(id) = self.linked_assets.get(p) { - return graphic(*id); + let size = self.assets.get(id).and_then(|a| svg_viewbox_size(&a.data)).unwrap_or(size); + return graphic(*id, size); } if let Some(d) = (self.read_link)(p) { data = d; read_from = Some(p.clone()); } } + // InDesign draws a viewBox-only SVG at one unit to the point: its GraphicBounds are the + // art's bounds, which can overhang the viewBox, so they must not size the viewBox. + let size = svg_viewbox_size(&data).unwrap_or(size); let (mime, px) = sniff_image(&data); let mime = mime.map(str::to_string).unwrap_or_else(|| match link.and_then(|k| k.get("LinkResourceFormat")).unwrap_or("") { f if f.contains("JPEG") => "image/jpeg".into(), @@ -2411,7 +2449,7 @@ impl<'r> Importer<'r> { if let Some(p) = read_from { self.linked_assets.insert(p, id); } - graphic(id) + graphic(id, size) } fn link_threads(&mut self) { diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index dd2761442..8c1ea2ee0 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -108,3 +108,46 @@ fn one_file_placed_several_times_imports_as_one_asset() { } } } + +/// An `` placement linking `uri`, whose GraphicBounds are `bounds` (left top right bottom). +fn svg(n: usize, uri: &str, bounds: [f64; 4]) -> String { + let [l, t, r, b] = bounds; + format!( + r#" + + + "# + ) +} + +#[test] +fn placed_svg_with_only_a_viewbox_keeps_its_viewbox_size() { + const OVERHANG: &str = + r##""##; + const SIZED: &str = + r#""#; + let reader = |p: &str| match p { + "/art/overhang.svg" => Some(OVERHANG.as_bytes().to_vec()), + "/art/sized.svg" => Some(SIZED.as_bytes().to_vec()), + _ => None, + }; + // GraphicBounds are the art's bounds: 211 wide for the overhanging one, placed twice (the second + // reuses the first's asset); a sized SVG is still stretched onto its bounds. + let frames = vec![ + frame(1, 0.0, 0.0, &svg(1, "file:/art/overhang.svg", [-11.0, 0.0, 200.0, 100.0])), + frame(2, 100.0, 0.0, &svg(2, "file:/art/overhang.svg", [-11.0, 0.0, 200.0, 100.0])), + frame(3, 200.0, 0.0, &svg(3, "file:/art/sized.svg", [-10.0, 0.0, 200.0, 100.0])), + ]; + let d = import_idml_with(&package(&frames), &reader).unwrap(); + let mut gs: Vec<_> = d.spreads[0] + .items + .iter() + .filter_map(|i| match &i.content { + Content::Graphic(g) => Some((i.xf.translation().x, g.size, g.xf.translation().x)), + _ => None, + }) + .collect(); + gs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let got: Vec<_> = gs.into_iter().map(|(_, size, x)| (size, x)).collect(); + assert_eq!(got, vec![((200.0, 100.0), -11.0), ((200.0, 100.0), -11.0), ((210.0, 100.0), -10.0)]); +} From e45bb864ed426199297ed693acc697edc152f9bb Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:52:43 +0300 Subject: [PATCH 084/128] math: cap LaTeX expression nesting depth so hostile input can't overflow the stack `math.svg` and `math.insert` take an unbounded `latex` string, and the parser's arg/atom/list/list_until_right recursive descent recursed once per group: 100k nested braces, `\frac`s or `\left(`s overflowed the stack (an abort the command guard cannot catch). Past MAX_NESTING (256) groups stop descending, so the rest of the input is consumed iteratively. Regression test: 100k nested braces, `\frac`s and `\left(`s typeset to an SVG; the old code aborted with a stack overflow. --- crates/engine/src/math.rs | 51 +++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/math.rs b/crates/engine/src/math.rs index 97327ed81..71d873ae2 100644 --- a/crates/engine/src/math.rs +++ b/crates/engine/src/math.rs @@ -160,9 +160,15 @@ fn big_op(name: &str) -> Option<&'static str> { }) } +/// Deepest expression nesting parsed. Recursive descent must not overflow the stack on hostile +/// input (`\frac\frac…`, `{{{{…`, `\left(\left(…`); past the cap groups stop descending. +const MAX_NESTING: usize = 256; + struct Parser<'a> { s: &'a [char], i: usize, + /// Enclosing group depth, at most [`MAX_NESTING`]. + depth: usize, } impl Parser<'_> { @@ -220,14 +226,19 @@ impl Parser<'_> { out } fn arg(&mut self) -> Node { + if self.depth >= MAX_NESTING { + return Node::Group(Vec::new()); + } + self.depth += 1; self.skip_ws(); - if self.peek() == Some('{') { + let n = if self.peek() == Some('{') { self.i += 1; - let g = self.list(Some('}')); - Node::Group(g) + Node::Group(self.list(Some('}'))) } else { self.atom().unwrap_or(Node::Group(vec![])) - } + }; + self.depth -= 1; + n } fn delimiter(&mut self) -> String { self.skip_ws(); @@ -280,7 +291,7 @@ impl Parser<'_> { } s.push(c); } - Box::new(parse(&s)) + Box::new(parse_at(&s, self.depth + 1)) }); let a = self.arg(); Node::Sqrt(idx, Box::new(a)) @@ -329,6 +340,10 @@ impl Parser<'_> { }) } fn list(&mut self, end: Option) -> Vec { + if self.depth >= MAX_NESTING { + return Vec::new(); + } + self.depth += 1; let mut out: Vec = Vec::new(); loop { self.skip_ws(); @@ -362,9 +377,13 @@ impl Parser<'_> { } } } + self.depth -= 1; out } fn list_until_right(&mut self) -> Vec { + if self.depth >= MAX_NESTING { + return Vec::new(); + } let mut out = Vec::new(); loop { self.skip_ws(); @@ -375,7 +394,7 @@ impl Parser<'_> { self.i += 6; break; } - let mut p = Parser { s: self.s, i: self.i }; + let mut p = Parser { s: self.s, i: self.i, depth: self.depth + 1 }; let one = p.list_one(); self.i = p.i; out.extend(one); @@ -410,8 +429,12 @@ impl Parser<'_> { } fn parse(src: &str) -> Node { + parse_at(src, 0) +} + +fn parse_at(src: &str, depth: usize) -> Node { let chars: Vec = src.chars().collect(); - let mut p = Parser { s: &chars, i: 0 }; + let mut p = Parser { s: &chars, i: 0, depth }; Node::Group(p.list(None)) } @@ -714,4 +737,18 @@ mod tests { let (svg, _) = to_svg(r"\sqrt{a^2+b^2} \ne \pi", 14.0); assert!(designcraft_images::svg_tree(&svg).is_some()); } + + /// Hostile input must not overflow the stack: nesting past the cap stops descending instead + /// of recursing once per group (100k levels used to abort the process). + #[test] + fn an_over_deep_expression_is_bounded() { + for latex in [ + format!("{}x{}", "{".repeat(100_000), "}".repeat(100_000)), + format!("{}x", r"\frac".repeat(100_000)), + format!("{}x", r"\left(".repeat(100_000)), + ] { + let (svg, _) = to_svg(&latex, 12.0); + assert!(!svg.is_empty()); + } + } } From a948ab288460fba7ddbcfc26731624a864e9e990 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 22:13:49 +0200 Subject: [PATCH 085/128] Pathfinder panel laid out as InDesign's, with Join Path and Convert Point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel was a row of text buttons (Add, Subtract, …, Make Compound, Release). InDesign's has four rows of icon buttons: Paths (Join, Open, Close, Reverse), Pathfinder, Convert Shape and Convert Point. The icons are new drawings in icons.rs. Two engine commands back the rows that had none: path.join (close an open path, or join two open paths at their nearest ends) and path.convertPoints (plain, corner, smooth, symmetrical, on the selected points or whole paths). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/anchors.rs | 201 +++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 8 + crates/ui-egui/src/i18n/ar.rs | 8 + crates/ui-egui/src/i18n/it.rs | 8 + crates/ui-egui/src/i18n/ja.rs | 7 + crates/ui-egui/src/i18n/pt_br.rs | 10 ++ crates/ui-egui/src/i18n/uk.rs | 8 + crates/ui-egui/src/icons.rs | 120 ++++++++++++++ crates/ui-egui/src/panels/properties.rs | 81 ++++++++-- docs/target-app-parity.md | 1 + 11 files changed, 437 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..9a770db0b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Pathfinder panel is laid out as InDesign's: Paths (Join, Open, Close, Reverse), Pathfinder, Convert Shape and Convert Point, each a row of icon buttons. Join Path closes an open path or joins two at their nearest ends (`path.join`), and Convert Point makes the selected points plain, corner, smooth or symmetrical (`path.convertPoints`). No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index c5015c7c4..0b72c4727 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -1,11 +1,12 @@ //! Anchor-point editing (the Pen tool group): Add Anchor Point, Delete Anchor Point and Convert -//! Direction Point. Positions are in spread coordinates; anchors are `(subpath, index)`. +//! Direction Point, and the Pathfinder panel's Join Path and Convert Point. Positions are in spread +//! coordinates; anchors are `(subpath, index)`. use designcraft_doc::{Document, ItemId, Shape}; use designcraft_geom::{Affine, Anchor, AnchorKind, Point}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, point_param}; +use super::{CommandSpec, bad, cmd, has_doc, has_selection, point_param}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -55,9 +56,147 @@ pub fn specs() -> Vec { has_doc, |s, p| erase_or_smooth(s, p, true) ), + cmd!( + "path.join", + "Join", + ["Object", "Paths"], + None, + "{ids?} — one open path: closes it; two open paths: joins them where their ends are nearest (coincident ends become one point) into the first, and removes the second → {id}", + has_selection, + join_paths + ), + cmd!( + "path.convertPoints", + "Convert Point", + [], + None, + "{kind: plain|corner|smooth|symmetrical, ids?} — the Direct Selection tool's selected points, or every point of the selected paths: plain (no direction handles), corner (independent handles), smooth (collinear handles), symmetrical (collinear, equal length) → {converted}", + has_selection, + convert_points + ), ] } +/// Join Path: close one open path, or join two open paths at their nearest ends. +fn join_paths(s: &mut Session, p: &Value) -> Result { + let ids = super::targets(s, p)?; + s.edit(|d, sel| { + let ids: Vec = + ids.iter().copied().filter(|id| d.item(*id).is_some_and(|it| !it.locked && !it.is_group() && it.text_frame().is_none())).collect(); + let open = |d: &Document, id: ItemId| d.item(id).and_then(|it| it.path.subpaths.iter().position(|sp| !sp.closed && sp.anchors.len() > 1)); + match ids.as_slice() { + [id] => { + let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?; + let mut closed = 0; + for sp in it.path.subpaths.iter_mut().filter(|sp| !sp.closed && sp.anchors.len() > 1) { + sp.closed = true; + closed += 1; + } + if closed == 0 { + return Err(bad("path.join", "the path is already closed")); + } + as_path(&mut it.shape); + Ok(json!({"id": id.0, "closed": closed})) + } + [a, b] => { + let (Some(sa), Some(sb)) = (open(d, *a), open(d, *b)) else { + return Err(bad("path.join", "select two open paths")); + }; + // b's path in a's space. + let inv_a = to_inner(d, *a).ok_or(designcraft_doc::DocError::NoItem(*a))?; + let xf_b = to_inner(d, *b).ok_or(designcraft_doc::DocError::NoItem(*b))?.inverse(); + let m = inv_a * xf_b; + let mut pb = d.item(*b).and_then(|it| it.path.subpaths.get(sb).cloned()).ok_or(designcraft_doc::DocError::NoItem(*b))?; + for an in &mut pb.anchors { + *an = Anchor { p: m * an.p, h_in: m * an.h_in, h_out: m * an.h_out, kind: an.kind }; + } + let ita = d.item_mut(*a).ok_or(designcraft_doc::DocError::NoItem(*a))?; + let pa = ita.path.subpaths.get_mut(sa).ok_or_else(|| bad("path.join", "no such subpath"))?; + // The pair of ends nearest each other; then a's end meets b's start. + let ends = |sp: &designcraft_geom::SubPath| (sp.anchors.first().map(|x| x.p), sp.anchors.last().map(|x| x.p)); + let ((Some(a0), Some(a1)), (Some(b0), Some(b1))) = (ends(pa), ends(&pb)) else { + return Err(bad("path.join", "select two open paths")); + }; + let pairs = [(a1, b0, false, false), (a1, b1, false, true), (a0, b0, true, false), (a0, b1, true, true)]; + let Some(&(_, _, rev_a, rev_b)) = pairs.iter().min_by(|x, y| (x.0 - x.1).hypot().total_cmp(&(y.0 - y.1).hypot())) else { + return Err(bad("path.join", "select two open paths")); + }; + if rev_a { + pa.reverse(); + } + if rev_b { + pb.reverse(); + } + let mut rest = pb.anchors.into_iter(); + if let Some(first) = rest.next() { + match pa.anchors.last_mut() { + Some(last) if (last.p - first.p).hypot() < 0.01 => *last = Anchor::with_handles(last.p, last.h_in, first.h_out), + _ => pa.anchors.push(first), + } + } + pa.anchors.extend(rest); + as_path(&mut ita.shape); + d.remove_item(*b)?; + *sel = designcraft_doc::Selection::items(vec![*a]); + Ok(json!({"id": a.0})) + } + _ => Err(bad("path.join", "select one or two open paths")), + } + }) +} + +/// Convert Point: the selected points (or every point of the selected paths) to a point type. +fn convert_points(s: &mut Session, p: &Value) -> Result { + let kind = super::str_param(p, "kind").unwrap_or("plain").to_string(); + if !matches!(kind.as_str(), "plain" | "corner" | "smooth" | "symmetrical") { + return Err(bad("path.convertPoints", format!("unknown kind `{kind}`"))); + } + let mut points: Vec<(ItemId, usize, usize)> = if p.get("ids").is_none() { s.doc()?.selection.anchors.clone() } else { Vec::new() }; + if points.is_empty() { + for id in super::targets(s, p)? { + if let Some(it) = s.doc()?.doc.item(id) { + points.extend(it.path.anchors().map(|(si, ai, _)| (id, si, ai))); + } + } + } + s.edit(|d, _| { + let mut converted = 0; + for (id, si, ai) in &points { + let Some(it) = d.item_mut(*id) else { continue }; + if it.locked || it.is_group() { + continue; + } + let Some(sp) = it.path.subpaths.get_mut(*si) else { continue }; + let Some(a) = sp.anchors.get(*ai).copied() else { continue }; + let auto = smooth_auto(&sp.anchors, *ai, sp.closed); + // The handles: the point's own, or ones worked out from its neighbours. + let (h_in, h_out) = if a.has_in() || a.has_out() { (a.h_in, a.h_out) } else { (auto.h_in, auto.h_out) }; + let new = match kind.as_str() { + "plain" => Anchor::corner(a.p), + "corner" => Anchor { p: a.p, h_in, h_out, kind: AnchorKind::Corner }, + _ => { + let dir = if (h_out - h_in).hypot() > 1e-9 { h_out - h_in } else { auto.h_out - auto.h_in }; + let len = dir.hypot(); + if len < 1e-9 { + a + } else { + let u = dir / len; + let (li, lo) = ((a.p - h_in).hypot(), (h_out - a.p).hypot()); + let (li, lo) = if kind == "symmetrical" { ((li + lo) / 2.0, (li + lo) / 2.0) } else { (li, lo) }; + Anchor { p: a.p, h_in: a.p - u * li, h_out: a.p + u * lo, kind: AnchorKind::Smooth } + } + } + }; + if let Some(slot) = sp.anchors.get_mut(*ai) { + *slot = new; + converted += 1; + } + as_path(&mut it.shape); + } + Ok(json!({"converted": converted})) + }) +} + /// Erase / Smooth: the stretch of one subpath touched by the drag. fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { let cmd = if smooth { "path.smooth" } else { "path.erase" }; @@ -253,6 +392,64 @@ pub fn anchor_near(d: &Document, id: ItemId, at: Point, tol: f64) -> Option<(usi mod tests { use super::*; + fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> u64 { + s.execute("line.create", &json!({"a": a, "b": b})).unwrap()["id"].as_u64().unwrap() + } + + /// Join Path closes one open path, and joins two open paths at their nearest ends. + #[test] + fn join_path_closes_one_path_and_joins_two() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = line(&mut s, [100.0, 100.0], [200.0, 100.0]); + // b runs back towards a: its end meets a's end at (200, 100). + let b = line(&mut s, [200.0, 200.0], [200.0, 100.0]); + let r = s.execute("path.join", &json!({"ids": [a, b]})).unwrap(); + assert_eq!(r["id"], a); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ItemId(b)).is_none()); + let sp = &d.item(ItemId(a)).unwrap().path.subpaths[0]; + assert_eq!(sp.anchors.len(), 3, "the coincident ends become one point"); + assert!(!sp.closed); + let bb = d.item(ItemId(a)).unwrap().bounds(); + assert!((bb.width() - 100.0).abs() < 0.5 && (bb.height() - 100.0).abs() < 0.5, "{bb:?}"); + // Joining it again closes it. + s.execute("path.join", &json!({"ids": [a]})).unwrap(); + assert!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].closed); + assert!(s.execute("path.join", &json!({"ids": [a]})).is_err(), "nothing left to close"); + // Ends apart are joined with a straight segment. + let c = line(&mut s, [300.0, 300.0], [400.0, 300.0]); + let e = line(&mut s, [300.0, 400.0], [400.0, 400.0]); + s.execute("path.join", &json!({"ids": [c, e]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(c)).unwrap().path.subpaths[0].anchors.len(), 4); + } + + /// Convert Point turns the selected points (or a whole path's) plain, corner, smooth or symmetrical. + #[test] + fn convert_point_kinds() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + let anchor = |s: &Session, i: usize| s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors[i]; + s.execute("path.convertPoints", &json!({"kind": "smooth", "ids": [id]})).unwrap(); + let a = anchor(&s, 0); + assert_eq!(a.kind, AnchorKind::Smooth); + assert!(a.has_in() && a.has_out()); + let (di, dout) = (a.p - a.h_in, a.h_out - a.p); + assert!((di.x * dout.y - di.y * dout.x).abs() < 1e-6, "collinear handles"); + s.execute("path.convertPoints", &json!({"kind": "symmetrical", "ids": [id]})).unwrap(); + let a = anchor(&s, 1); + assert!(((a.p - a.h_in).hypot() - (a.h_out - a.p).hypot()).abs() < 1e-6, "equal handles"); + s.execute("path.convertPoints", &json!({"kind": "plain", "ids": [id]})).unwrap(); + let a = anchor(&s, 2); + assert!(!a.has_in() && !a.has_out() && a.kind == AnchorKind::Corner); + s.execute("path.convertPoints", &json!({"kind": "corner", "ids": [id]})).unwrap(); + let a = anchor(&s, 2); + assert!(a.has_in() && a.has_out() && a.kind == AnchorKind::Corner, "a corner point gets independent handles"); + assert!(s.execute("path.convertPoints", &json!({"kind": "nope", "ids": [id]})).is_err()); + assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().shape, Shape::Path); + } + #[test] fn erase_and_smooth_along_a_drag() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 04ee153c0..ea6c2d595 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Paths:", ["Pfade:", "Tracés :", "Trazados:", "パス:", "路径:"]), + ("Pathfinder:", ["Pathfinder:", "Pathfinder :", "Buscatrazos:", "パスファインダー:", "路径查找器:"]), + ("Convert Shape:", ["Form konvertieren:", "Convertir la forme :", "Convertir forma:", "シェイプを変換:", "转换形状:"]), + ("Convert Point:", ["Punkt konvertieren:", "Convertir le point :", "Convertir punto:", "ポイントを変換:", "转换点:"]), + ("Join Path", ["Pfad zusammenfügen", "Joindre le tracé", "Unir trazado", "パスを連結", "连接路径"]), + ("Open Path", ["Pfad öffnen", "Ouvrir le tracé", "Abrir trazado", "パスをオープン", "开放路径"]), + ("Plain", ["Einfach", "Simple", "Normal", "プレーン", "普通"]), + ("Symmetrical", ["Symmetrisch", "Symétrique", "Simétrico", "対称", "对称"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index f68e7587e..264ca414a 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Paths:", "المسارات:"), + ("Pathfinder:", "مستكشف المسار:"), + ("Convert Shape:", "تحويل الشكل:"), + ("Convert Point:", "تحويل النقطة:"), + ("Join Path", "ربط المسار"), + ("Open Path", "فتح المسار"), + ("Plain", "عادي"), + ("Symmetrical", "متماثل"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index e91307f18..2bcdf8dcf 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Paths:", "Tracciati:"), + ("Pathfinder:", "Elaborazione tracciati:"), + ("Convert Shape:", "Converti forma:"), + ("Convert Point:", "Converti punto:"), + ("Join Path", "Unisci tracciato"), + ("Open Path", "Apri tracciato"), + ("Plain", "Semplice"), + ("Symmetrical", "Simmetrico"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index f45ee094b..3c9356254 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Paths:", "パス:"), + ("Pathfinder:", "パスファインダー:"), + ("Convert Shape:", "シェイプを変換:"), + ("Convert Point:", "ポイントを変換:"), + ("Join Path", "パスを連結"), + ("Plain", "プレーン"), + ("Symmetrical", "対称"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 09e639e5c..1669426f4 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Paths:", "Demarcadores:"), + ("Pathfinder:", "Pathfinder:"), + ("Convert Shape:", "Converter forma:"), + ("Convert Point:", "Converter ponto:"), + ("Join Path", "Unir demarcador"), + ("Open Path", "Abrir demarcador"), + ("Close Path", "Fechar demarcador"), + ("Plain", "Simples"), + ("Smooth", "Suave"), + ("Symmetrical", "Simétrico"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index eab4159d0..1552c27d6 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Paths:", "Контури:"), + ("Pathfinder:", "Обробка контурів:"), + ("Convert Shape:", "Перетворити фігуру:"), + ("Convert Point:", "Перетворити точку:"), + ("Join Path", "Об’єднати контур"), + ("Open Path", "Розімкнути контур"), + ("Plain", "Простий"), + ("Symmetrical", "Симетричний"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c93464374..9da72e6e5 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -550,6 +550,94 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { } } } + // Pathfinder panel › Paths: a join point, a path opened and one closed, two opposed arrows. + "path-join" => { + pen.line(&[(3.0, 16.0), (5.0, 12.0), (10.0, 10.0)]); + pen.line(&[(10.0, 10.0), (15.0, 8.0), (17.0, 4.0)]); + pen.fcircle(10.0, 10.0, 2.2); + } + "path-open" | "path-close" => { + let gap = if name == "path-open" { 0.9 } else { 0.0 }; + let n = 28; + let pts: Vec<(f32, f32)> = (0..=n) + .map(|i| { + let a = -std::f32::consts::FRAC_PI_2 + gap / 2.0 + (std::f32::consts::TAU - gap) * i as f32 / n as f32; + (10.0 + 6.5 * a.cos(), 10.5 + 6.5 * a.sin()) + }) + .collect(); + pen.line(&pts); + if name == "path-open" { + pen.frect(11.0, 2.5, 14.0, 5.5); + pen.frect(6.0, 2.5, 9.0, 5.5); + } else { + pen.fcircle(10.0, 4.0, 1.8); + } + } + "path-reverse" => { + pen.line(&[(3.0, 7.0), (16.0, 7.0)]); + pen.line(&[(13.0, 4.0), (16.5, 7.0), (13.0, 10.0)]); + pen.line(&[(17.0, 13.0), (4.0, 13.0)]); + pen.line(&[(7.0, 10.0), (3.5, 13.0), (7.0, 16.0)]); + } + // Pathfinder panel › Convert Shape: the shape outlined. + "shape-rect" => pen.rect(3.0, 5.0, 17.0, 15.0), + "shape-rounded" | "shape-beveled" | "shape-inverse" => { + let (x0, y0, x1, y1, r): (f32, f32, f32, f32, f32) = (3.0, 5.0, 17.0, 15.0, 3.5); + // Clockwise from the top left: each corner's two ends, and the centre of its arc + // (inside for a rounded corner, the corner itself for an inverse rounded one). + let corners = [ + ((x0, y0), (x0, y0 + r), (x0 + r, y0), (x0 + r, y0 + r)), + ((x1, y0), (x1 - r, y0), (x1, y0 + r), (x1 - r, y0 + r)), + ((x1, y1), (x1, y1 - r), (x1 - r, y1), (x1 - r, y1 - r)), + ((x0, y1), (x0 + r, y1), (x0, y1 - r), (x0 + r, y1 - r)), + ]; + let mut pts = Vec::new(); + for (corner, a, b, inner) in corners { + if name == "shape-beveled" { + pts.extend([a, b]); + continue; + } + let c = if name == "shape-inverse" { corner } else { inner }; + let a0 = (a.1 - c.1).atan2(a.0 - c.0); + let mut d = (b.1 - c.1).atan2(b.0 - c.0) - a0; + if d > std::f32::consts::PI { + d -= std::f32::consts::TAU; + } else if d < -std::f32::consts::PI { + d += std::f32::consts::TAU; + } + pts.extend((0..=6).map(|k| { + let t = a0 + d * k as f32 / 6.0; + (c.0 + r * t.cos(), c.1 + r * t.sin()) + })); + } + pen.closed(&pts); + } + "shape-ellipse" => pen.ellipse(10.0, 10.0, 7.5, 5.0), + "shape-triangle" => pen.closed(&[(10.0, 3.5), (17.0, 16.0), (3.0, 16.0)]), + "shape-polygon" => pen.closed(&[(6.5, 4.0), (13.5, 4.0), (17.0, 10.0), (13.5, 16.0), (6.5, 16.0), (3.0, 10.0)]), + "shape-line" => pen.line(&[(4.0, 16.0), (16.0, 4.0)]), + "shape-orthogonal" => { + pen.line(&[(10.0, 3.0), (10.0, 17.0)]); + pen.line(&[(3.0, 10.0), (17.0, 10.0)]); + } + // Pathfinder panel › Convert Point: a point on a path, with its direction handles. + "point-plain" | "point-corner" | "point-smooth" | "point-symmetrical" => { + match name { + "point-plain" | "point-corner" => pen.line(&[(3.0, 16.0), (10.0, 8.0), (17.0, 16.0)]), + _ => pen.line(&[(3.0, 16.0), (5.5, 11.5), (10.0, 8.0), (14.5, 11.5), (17.0, 16.0)]), + } + let handles: &[(f32, f32)] = match name { + "point-corner" => &[(4.5, 4.0), (16.0, 5.0)], + "point-smooth" => &[(5.0, 8.0), (17.5, 8.0)], + "point-symmetrical" => &[(4.0, 8.0), (16.0, 8.0)], + _ => &[], + }; + for &(hx, hy) in handles { + pen.line(&[(10.0, 8.0), (hx, hy)]); + pen.fcircle(hx, hy, 1.4); + } + pen.frect(8.5, 6.5, 11.5, 9.5); + } // ---- Properties panel glyphs (original drawings) "orient-portrait" => { pen.rect(5.5, 3.0, 14.5, 17.0); @@ -885,6 +973,38 @@ mod tests { format!("{:?}", out.shapes.iter().map(|s| &s.shape).collect::>()) } + /// The Pathfinder panel's icons are all drawn, and all differ. + #[test] + fn pathfinder_panel_icons_are_drawn() { + let names = [ + "path-join", + "path-open", + "path-close", + "path-reverse", + "shape-rect", + "shape-rounded", + "shape-beveled", + "shape-inverse", + "shape-ellipse", + "shape-triangle", + "shape-polygon", + "shape-line", + "shape-orthogonal", + "point-plain", + "point-corner", + "point-smooth", + "point-symmetrical", + ]; + let unknown = drawing("no-such-icon"); + let drawn: Vec = names.iter().map(|n| drawing(n)).collect(); + for (i, (n, d)) in names.iter().zip(&drawn).enumerate() { + assert_ne!(*d, unknown, "{n} falls back to the placeholder"); + for (m, e) in names.iter().zip(&drawn).skip(i + 1) { + assert_ne!(d, e, "{n} draws like {m}"); + } + } + } + #[test] fn transform_tools_have_their_own_icons() { let names = ["tool-free-transform", "tool-scale", "tool-shear"]; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 358ec76d3..b84e1dd2f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1841,26 +1841,79 @@ fn pathfinder_row(app: &mut DesignApp, ui: &mut Ui) { }); } -/// Pathfinder panel: combine the selected shapes; make or release compound paths. +/// Pathfinder panel › Paths: (icon, command, params, tooltip). +const PATHS: [(&str, &str, &str, &str); 4] = [ + ("path-join", "path.join", "{}", "Join Path"), + ("path-open", "object.convertShape", r#"{"to": "openPath"}"#, "Open Path"), + ("path-close", "object.convertShape", r#"{"to": "closedPath"}"#, "Close Path"), + ("path-reverse", "path.reverse", "{}", "Reverse Path"), +]; + +/// Pathfinder panel › Convert Shape: (icon, shape, tooltip). +const CONVERT_SHAPE: [(&str, &str, &str); 9] = [ + ("shape-rect", "rectangle", "Rectangle"), + ("shape-rounded", "roundedRectangle", "Rounded Rectangle"), + ("shape-beveled", "beveledRectangle", "Beveled Rectangle"), + ("shape-inverse", "inverseRoundedRectangle", "Inverse Rounded Rectangle"), + ("shape-ellipse", "ellipse", "Ellipse"), + ("shape-triangle", "triangle", "Triangle"), + ("shape-polygon", "polygon", "Polygon"), + ("shape-line", "line", "Line"), + ("shape-orthogonal", "orthogonalLine", "Orthogonal Line"), +]; + +/// Pathfinder panel › Convert Point: (icon, kind, tooltip). +const CONVERT_POINT: [(&str, &str, &str); 4] = [ + ("point-plain", "plain", "Plain"), + ("point-corner", "corner", "Corner"), + ("point-smooth", "smooth", "Smooth"), + ("point-symmetrical", "symmetrical", "Symmetrical"), +]; + +/// Pathfinder panel, as InDesign's: Paths, Pathfinder, Convert Shape and Convert Point, each a row of +/// icon buttons acting on the selection. pub fn pathfinder_panel(app: &mut DesignApp, ui: &mut egui::Ui) { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Pathfinder")).strong()); - ui.horizontal_wrapped(|ui| { - for (label, op) in - [("Add", "add"), ("Subtract", "subtract"), ("Intersect", "intersect"), ("Exclude Overlap", "exclude"), ("Minus Back", "minusBack")] - { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + let section = |ui: &mut egui::Ui, app: &DesignApp, title: &str| { + ui.add_space(4.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, title)); + ui.add_space(2.0); + }; + let row = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui)| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(12.0, 8.0); + add(ui); + }); + }; + section(ui, app, "Paths:"); + row(ui, &mut |ui| { + for (icon, cmd, params, tip) in PATHS { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run(cmd, serde_json::from_str(params).unwrap_or_default()); + } + } + }); + section(ui, app, "Pathfinder:"); + row(ui, &mut |ui| { + for (icon, op, tip) in PATHFINDER { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { let _ = app.run("object.pathfinder", json!({"op": op})); } } }); - ui.add_space(4.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paths")).strong()); - ui.horizontal(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Make Compound"))).clicked() { - let _ = app.run("object.makeCompoundPath", json!({})); + section(ui, app, "Convert Shape:"); + row(ui, &mut |ui| { + for (icon, to, tip) in CONVERT_SHAPE { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("object.convertShape", json!({"to": to})); + } } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Release"))).clicked() { - let _ = app.run("object.releaseCompoundPath", json!({})); + }); + section(ui, app, "Convert Point:"); + row(ui, &mut |ui| { + for (icon, kind, tip) in CONVERT_POINT { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("path.convertPoints", json!({"kind": kind})); + } } }); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a97fad6..b2ea5756f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -417,6 +417,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read. - Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. +- Pathfinder panel as InDesign's: Paths (Join Path `path.join`, Open/Close Path, Reverse Path), Pathfinder, Convert Shape, Convert Point (`path.convertPoints`: plain, corner, smooth, symmetrical). - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). - Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. From 0fe2373c741dd9eb913a0b41ed30f572347427fa Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:14:07 -0700 Subject: [PATCH 086/128] Fix the AppStream screenshot URL and caption Assisted-by: OpenAI Codex (GPT-6) Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- packaging/linux/ai.storyteller.designcraft.metainfo.xml.in | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/linux/ai.storyteller.designcraft.metainfo.xml.in b/packaging/linux/ai.storyteller.designcraft.metainfo.xml.in index ea548de84..f3fd91535 100644 --- a/packaging/linux/ai.storyteller.designcraft.metainfo.xml.in +++ b/packaging/linux/ai.storyteller.designcraft.metainfo.xml.in @@ -35,8 +35,8 @@ - Editing The Great Wave off Kanagawa with type layers and adjustment layers - https://raw.githubusercontent.com/storytold/designcraft/main/docs/images/designcraft-demo.jpg + Magazine spread with threaded text frames and layout properties + https://raw.githubusercontent.com/storytold/designcraft/main/docs/images/ui-spread.png From 017b925189565c8397ca294812011e9840836e0f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 22:31:32 +0200 Subject: [PATCH 087/128] Placeholder paragraphs as short as InDesign's (2 to 5 sentences) Measured in Zero2Graphic's video: InDesign's placeholder paragraphs run 5, 10 (at 18 pt), 6 and 8 lines in a 5.9 cm column, about 2, 2, 3 and 3 of DesignCraft's filler sentences. The rhythm started 3, 4, 2, 5. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index f6ed4eca5..52c3588c5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1398,9 +1398,9 @@ fn fill_placeholder(s: &mut Session, p: &Value) -> Result { story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))? } }; - // Fill until overset with paragraphs of a few sentences each, as InDesign's placeholder text + // Fill until overset with paragraphs of two to five sentences, as InDesign's placeholder text // comes (so a paragraph can be styled on its own). - const SENTENCES_PER_PARAGRAPH: [usize; 6] = [3, 4, 2, 5, 3, 4]; + const SENTENCES_PER_PARAGRAPH: [usize; 7] = [2, 2, 3, 3, 5, 2, 4]; let mut text = String::new(); let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").map(str::trim_end).collect(); let doc = s.doc()?.doc.clone(); From 8cfaa90dcb036698ea54fa6c719b8d79c66b8fa5 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:46:19 +0300 Subject: [PATCH 088/128] idml: read PDFAttribute PageNumber so placed PDFs show their page A placement of a multi-page PDF carried no page number through IDML: graphic() always made the asset with page 0 and keyed linked_assets by path alone, so every placement of one file showed page 1 (InDesign's PDFAttribute PageNumber was ignored). Import now reads PDFAttribute/@PageNumber (1-based) into Asset.page, linked_assets is keyed by (path, page), and export writes PDFAttribute back for pages past the first. Regression test: the same file placed at pages 2, 1 and 2 imports as two assets with pages 1 and 0 and round-trips through export. --- crates/idml/src/export.rs | 5 +++++ crates/idml/src/import.rs | 28 ++++++++++++++++------- crates/idml/src/tests/assets.rs | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0b9435c71..e99d0a4e8 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1884,6 +1884,11 @@ impl<'a> Ex<'a> { .attr("ImportPolicy", "NoAutoImport") .attr("ExportPolicy", "NoAutoExport"), ); + if is_pdf && asset.page > 0 { + // PDF Import Options: the page is 1-based; page 1 (the default) needs no element, as + // InDesign omits it too. + el.push(El::new("PDFAttribute").attr("PageNumber", asset.page.saturating_add(1).to_string())); + } el } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..c72237964 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -194,8 +194,9 @@ struct Importer<'r> { page_index: HashMap, item_ids: HashMap, assets: BTreeMap>, - /// Assets read from a link path, so later placements of that path reuse them unread. - linked_assets: HashMap, + /// Assets read from a link path at a page, so later placements of that path and page reuse + /// them unread. + linked_assets: HashMap<(String, u32), AssetId>, ctx: ItemCtx, sections: Vec
    , footnote_options: designcraft_doc::FootnoteOptions, @@ -2377,17 +2378,28 @@ impl<'r> Importer<'r> { }) }; let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default(); + // PDF Import Options: `PDFAttribute/@PageNumber` is 1-based; `Asset.page` is 0-based. A + // missing or malformed value means page 1, and an out-of-range page later fails to render + // rather than panicking. + let page = g + .find("PDFAttribute") + .or_else(|| g.prop_el("PDFAttribute")) + .and_then(|a| a.get("PageNumber")) + .and_then(|v| v.trim().parse::().ok()) + .and_then(|n| u32::try_from(n.saturating_sub(1)).ok()) + .unwrap_or(0); let mut read_from = None; if data.is_empty() && let Some(p) = &uri { - // Every placement of a linked file shares one asset; read the file once. - if let Some(id) = self.linked_assets.get(p) { + // Every placement of a linked file at one page shares one asset; read the file once. + let key = (p.clone(), page); + if let Some(id) = self.linked_assets.get(&key) { return graphic(*id); } if let Some(d) = (self.read_link)(p) { data = d; - read_from = Some(p.clone()); + read_from = Some(key); } } let (mime, px) = sniff_image(&data); @@ -2407,9 +2419,9 @@ impl<'r> Importer<'r> { let fresh = AssetId(self.alloc()); let link_path = uri.filter(|p| p.contains('/') || p.contains('\\')); let id = - designcraft_doc::insert_asset(&mut self.assets, Asset { page: 0, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels }); - if let Some(p) = read_from { - self.linked_assets.insert(p, id); + designcraft_doc::insert_asset(&mut self.assets, Asset { page, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels }); + if let Some(key) = read_from { + self.linked_assets.insert(key, id); } graphic(id) } diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index dd2761442..d4d1501f2 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -31,6 +31,22 @@ fn image(n: usize, uri: &str, contents: Option<&[u8]>) -> String { ) } +/// A graphic frame showing `pdf` (a `` element) with the page `PageNumber` (1-based). +fn pdf_frame(n: usize, x: f64, page: u32) -> String { + format!( + r#" + + + + + + + + + "# + ) +} + fn package(frames: &[String]) -> Vec { let spread = format!( r#" @@ -108,3 +124,27 @@ fn one_file_placed_several_times_imports_as_one_asset() { } } } + +/// `PDFAttribute/@PageNumber` (1-based) is read into `Asset.page` (0-based), and the same file at +/// different pages stays apart (#905). +#[test] +fn a_pdf_placement_reads_its_page_number() { + let bytes = b"%PDF-1.7\n% fixture".to_vec(); + let reader = |p: &str| p.ends_with("insert.pdf").then(|| bytes.clone()); + let frames = vec![pdf_frame(1, 0.0, 2), pdf_frame(2, 100.0, 1), pdf_frame(3, 200.0, 2)]; + let d = import_idml_with(&package(&frames), &reader).unwrap(); + let p = placements(&d); + let page = |id| d.assets.get(&id).map(|a| a.page); + assert_eq!(page(p[0].0), Some(1), "PageNumber=\"2\" is the second page, stored 0-based"); + assert_eq!(page(p[1].0), Some(0), "PageNumber=\"1\" is the first page"); + assert_eq!(p[0].0, p[2].0, "the same file at the same page shares one asset"); + assert_ne!(p[0].0, p[1].0, "the same file at another page is another asset"); + + // Exported (linked or embedded), the page survives the round trip. + for embed_images in [false, true] { + let out = export_idml_with(&d, &ExportOptions { embed_images }); + let back = import_idml_with(&out, &reader).unwrap(); + let pages: Vec<_> = placements(&back).iter().map(|(id, _, _)| back.assets.get(id).map(|a| a.page)).collect(); + assert_eq!(pages, vec![Some(1), Some(0), Some(1)], "embed_images: {embed_images}"); + } +} From 7a1ba45d1891cf4e6ef7f23f1a11db2f26aa2943 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:47:55 +0300 Subject: [PATCH 089/128] layout.section: keep the settings the command does not name The command removed the section at a nonzero start with retain() and only then looked it up to keep its properties, so the lookup always fell back to the defaults: editing one field (a marker, a start number) silently reset the style, prefix, include-prefix flag and start number. The existing section is now read before the list is rebuilt. Regression test: a Roman-numbered page-2 section with prefix A- and marker First, then marker Second keeps startNumber, style, prefix and includePrefix. --- crates/engine/src/cmd/layout.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 6d6e06ee1..79a555f3b 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -476,11 +476,14 @@ pub fn specs() -> Vec { let start = page - 1; let p = p.clone(); s.edit(|d, _| { + // The settings the command does not name stay as the section was: read it + // before the list is rebuilt (#717). + let existing = d.sections.iter().find(|x| x.start == start).cloned(); d.sections.retain(|x| x.start != start || start == 0); if p.get("remove").and_then(Value::as_bool).unwrap_or(false) && start != 0 { return ok(); } - let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { + let mut sec = existing.unwrap_or(designcraft_doc::Section { start, start_number: None, style: Default::default(), @@ -1034,6 +1037,28 @@ mod page_numbering_view_tests { assert_eq!(s.resolve_page("1"), Some(0)); assert_eq!(s.resolve_page("9"), None); } + + /// `layout.section` edits one named setting; the others stay as the section was (#717). + #[test] + fn updating_a_section_keeps_settings_the_command_does_not_name() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + s.execute( + "layout.section", + &json!({"page": 2, "startNumber": 1, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "marker": "First"}), + ) + .unwrap(); + assert_eq!(s.page_label(1), "A-i"); + s.execute("layout.section", &json!({"page": 2, "marker": "Second"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let sec = d.sections.iter().find(|x| x.start == 1).expect("the page-2 section is still the only one there"); + assert_eq!(sec.marker, "Second"); + assert_eq!(sec.start_number, Some(1), "startNumber is kept"); + assert_eq!(sec.style, designcraft_doc::NumberStyle::LowerRoman, "style is kept"); + assert_eq!(sec.prefix, "A-", "prefix is kept"); + assert!(sec.include_prefix, "includePrefix is kept"); + assert_eq!(s.page_label(1), "A-i"); + } } #[cfg(test)] From 59bdb371ff82d15318132fa3ab234151a9061e43 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:54:04 +0300 Subject: [PATCH 090/128] dtd: cap parameter-entity expansion instead of trusting a pass count parse() expanded parameter entities with up to eight passes of whole-string replacement, each pass cloning the text and re-inserting the entities' original values. A pass count is not an output bound: a few hundred bytes of nested entities inflate multiplicatively (a 449-byte DTD reaches ~4 MB here, and larger chains would go to gigabytes), so a small file could exhaust memory and CPU. Expansion now stops once it has added more than 1 MiB over the input. Regression test: a nested-entity DTD under 500 bytes is rejected with the budget error, and a small inline entity still parses. --- crates/engine/src/dtd.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/engine/src/dtd.rs b/crates/engine/src/dtd.rs index f20261a1e..7f752efaa 100644 --- a/crates/engine/src/dtd.rs +++ b/crates/engine/src/dtd.rs @@ -3,6 +3,10 @@ use std::collections::HashMap; +/// How much text parameter entities may add during expansion (a pass count is not an output +/// bound: each pass replaces whole strings, so references can inflate multiplicatively). +const MAX_EXPANDED_BYTES: usize = 1024 * 1024; + /// A content particle: a name, a sequence, a choice, or one of those repeated (`?`, `*`, `+`). #[derive(Clone, Debug, PartialEq)] enum Cp { @@ -177,10 +181,17 @@ pub fn parse(src: &str) -> Result { } } let mut text = src.to_string(); + // A pass count bounds the work, not the output: each pass replaces whole strings with the + // entities' original values, so a few hundred bytes of nested parameter entities can inflate + // to gigabytes. Expansion may not add more than this much text over the input. + let limit = src.len().saturating_add(MAX_EXPANDED_BYTES); for _ in 0..8 { let before = text.clone(); for (n, v) in &ents { text = text.replace(&format!("%{n};"), v); + if text.len() > limit { + return Err(format!("parameter entities expand the DTD beyond {limit} bytes")); + } } if text == before { break; @@ -434,6 +445,23 @@ mod tests { assert!(parse("just text").is_err()); } + /// A few hundred bytes of nested parameter entities used to inflate multiplicatively across + /// the eight wholly-string-replacing passes (#713); expansion is capped. + #[test] + fn parameter_entities_cannot_expand_without_a_budget() { + let mut src = String::from("\n"); + for i in 2..=6 { + src.push_str(&format!("\n", format!("%e{};", i - 1).repeat(16))); + } + src.push_str(""); + assert!(src.len() < 500, "the payload is small: {} bytes", src.len()); + let err = parse(&src).unwrap_err(); + assert!(err.contains("expand the DTD beyond"), "{err}"); + // Entities that expand a little still parse. + let dtd = parse("\n").unwrap(); + assert_eq!(dtd.element_names().collect::>(), ["r"]); + } + #[test] fn repetition_matches_like_a_regular_expression() { let dtd = parse("").unwrap(); From a393ab2018e845e0303da60205b80904a8b16ecc Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:57:21 +0300 Subject: [PATCH 091/128] script.run: roll a failed script back instead of stranding its edits A script whose later step failed kept the successful prefix in the document but truncated the prefix's undo entries (and returned before the command's aggregate undo entry was recorded), so the edits could not be undone at all. A failed script now restores the document, selection and history from before it started: all-or-nothing. Regression test: frame.create followed by an unknown command is an error, the document has no frame, nothing is left to undo, and the next edit undoes normally. The one-undo-step success path still holds. --- crates/engine/src/cmd/scripts.rs | 40 +++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 0fa89c598..5b6d288aa 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -1,5 +1,7 @@ //! Window › Utilities › Scripts: run a command script (see [`crate::script`]) as one undo step. +use std::sync::Arc; + use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, str_param}; @@ -8,15 +10,23 @@ use crate::Result; fn run(s: &mut crate::Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("script.run", "`text` required"))?; let steps = crate::script::parse(text).map_err(|e| bad("script.run", e))?; - // The steps' own undo entries collapse into this command's single one. - let uid = s.active().map(|d| d.uid); - let before = s.active().map_or(0, |d| d.history.undo.len()); + // The steps' own undo entries collapse into this command's single one. A step that fails + // rolls the successful prefix back (document, selection and history), so a half-run script + // never strands edits that can't be undone (#709). + let before = s.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.history.clone())); let report = s.run_script(&steps); if let Some(st) = s.active_mut() - && Some(st.uid) == uid - && st.history.undo.len() > before + && let Some((uid, doc, selection, history)) = &before + && st.uid == *uid { - st.history.undo.truncate(before); + if report.failed.is_some() { + st.doc = Arc::clone(doc); + st.selection = selection.clone(); + st.history = history.clone(); + st.revision = st.revision.saturating_add(1); + } else if st.history.undo.len() > history.undo.len() { + st.history.undo.truncate(history.undo.len()); + } } let out = json!({"results": report.results, "steps": steps.len()}); match report.failed { @@ -56,4 +66,22 @@ mod tests { assert_eq!(count(&s), 0, "one undo removes the whole script's work"); assert!(s.execute("script.run", &json!({"text": "nope.command"})).is_err()); } + + /// A script that fails after a successful step leaves the document and its history exactly as + /// they were: the half-run prefix is not stranded without an undo entry (#709). + #[test] + fn a_failed_script_rolls_the_successful_prefix_back() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let count = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); + let err = s.execute("script.run", &json!({"text": "frame.create {\"rect\": [72, 72, 200, 200]}\nnope.command"})).unwrap_err(); + assert!(err.to_string().contains("step 1") && err.to_string().contains("nope.command"), "{err}"); + assert_eq!(count(&s), 0, "the successful prefix is rolled back"); + assert!(s.execute("edit.undo", &json!({})).is_err(), "nothing was left to undo"); + // The session keeps working and its undo history is as it was. + s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap(); + assert_eq!(count(&s), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(count(&s), 0); + } } From e8dd2636f5e8c2d6f953bdd4949bc6ace6875631 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:01:55 +0300 Subject: [PATCH 092/128] guard: restore the whole command transaction on a panic The guard restored only the active document's content and selection, so a command that edited through a nested successful command and then panicked left the nested edit's undo entry (and no redo) behind, and a command that opened or switched documents restored nothing at all. The guard now snapshots every open document with its history and selection, the active index and the journal, and restores them on a panic. Regression tests: a nested edit followed by a panic leaves the history and redo exactly as they were, and a command that opens a second document and panics leaves only the original open. --- crates/engine/src/guard.rs | 63 +++++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 9a1df3eef..74e16b882 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -35,17 +35,23 @@ pub fn install_panic_hook() { impl Session { /// Run command `id` (`f`) inside the guard. pub(crate) fn guarded(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { - let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.revision)); + // The whole command transaction: every open document with its undo/redo history and + // selection, which one is active, and the journal (nested commands journal too). + let docs = self.docs.clone(); + let active = self.active; + let revision = self.active().map_or(0, |d| d.revision); + let journal = self.journal.len(); match catch_unwind(AssertUnwindSafe(|| f(self))) { Ok(r) => r, Err(payload) => { let msg = panic_message(payload.as_ref()); - // Keep the document as it was before the command. - if let (Some((uid, doc, selection, revision)), Some(st)) = (before, self.active_mut()) - && st.uid == uid - { - st.doc = doc; - st.selection = selection; + // Keep the documents as they were before the command: a nested edit that + // succeeded before the panic is rolled back with its history entry, a document + // opened or switched by the command is gone, and redo it cleared is back. + self.docs = docs; + self.active = active; + self.journal.truncate(journal); + if let Some(st) = self.active_mut() { st.interaction = None; st.revision = revision.max(st.revision).saturating_add(1); } @@ -86,4 +92,47 @@ mod tests { // The session keeps working. s.execute("frame.create", &json!({"rect": [36, 236, 200, 400]})).unwrap(); } + + /// A nested edit that succeeded before the panic is rolled back with its history entry, and + /// the redo it cleared is back (#742). + #[test] + fn a_panicking_command_restores_history_with_the_document() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let (undo, redo) = { + let h = &s.active().unwrap().history; + (h.undo.len(), h.redo.len()) + }; + assert_eq!(redo, 1, "there is redo to lose"); + let err = s + .guarded("test.panic", |s| { + s.execute("frame.create", &json!({"rect": [36, 236, 200, 400]}))?; + panic!("boom"); + }) + .unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + let st = s.active().unwrap(); + assert_eq!(st.history.undo.len(), undo, "the nested edit's undo entry is gone with the edit"); + assert_eq!(st.history.redo.len(), redo, "the redo the nested edit cleared is back"); + assert_eq!(st.doc.spreads[0].items.len(), 0, "the document is the one from before the command"); + } + + /// A command that opens another document and then panics leaves none of it behind (#742). + #[test] + fn a_panicking_command_restores_the_document_set() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let err = s + .guarded("test.panic", |s| { + s.execute("file.new", &json!({}))?; + assert_eq!(s.docs.len(), 2); + panic!("boom"); + }) + .unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + assert_eq!(s.docs.len(), 1, "the document the command opened is gone"); + assert_eq!(s.active, Some(0), "the document from before the command is active"); + } } From 939d5676cb5ef970650f6aa1704611097fd8f202 Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Wed, 14 Oct 2026 23:17:18 +0200 Subject: [PATCH 093/128] Color panel: the Tint field keeps typed text and applies it on Enter or leaving the field The Tint row's value box was the egui Slider's own DragValue. The panel reads the tint back from the document every frame, so egui dropped the text being edited after the first digit (100 -> 6 -> 100 -> 1000, as #945 reports for the CMYK fields), and a typed tint was never applied because the panel only applied on the slider's drag_stopped(). The slider keeps applying on release, without its own value box; the value is a widgets::NumField that buffers text while focused and applies on Enter or focus loss, clamped to 0-100 %. Part of #945. --- ROADMAP.md | 1 + .../ui-egui/src/panels/color_panel_tests.rs | 83 +++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 3 + crates/ui-egui/src/panels/swatches.rs | 17 +++- 4 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/panels/color_panel_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..dda2d5e99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Color panel's Tint field keeps what is typed while it has focus and applies it on Enter or when it loses focus, clamped to 0–100 %. Typing 60 over 100 % no longer snaps back to 100 after the first digit (10060), and a typed tint is no longer ignored. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/ui-egui/src/panels/color_panel_tests.rs b/crates/ui-egui/src/panels/color_panel_tests.rs new file mode 100644 index 000000000..cd102b2e3 --- /dev/null +++ b/crates/ui-egui/src/panels/color_panel_tests.rs @@ -0,0 +1,83 @@ +//! Color panel: the Tint field of a named swatch. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::DesignApp; + +fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + Harness::builder().with_size(vec2(320.0, 320.0)).build_ui_state(|ui, app: &mut DesignApp| super::swatches::color_panel(app, ui), app) +} + +fn fill_tint(app: &DesignApp, id: u64) -> f32 { + app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.tint +} + +/// The Tint row is the panel's first value field, above the C/M/Y/K fields. +fn click_tint_field(h: &mut Harness<'static, DesignApp>) { + let at = h + .query_all_by(|n| matches!(n.role(), Role::SpinButton | Role::TextInput)) + .map(|n| n.rect()) + .min_by(|a, b| a.min.y.total_cmp(&b.min.y)) + .unwrap() + .center(); + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(2); +} + +fn typed(h: &Harness<'static, DesignApp>) -> Option { + h.query_by(|n| n.is_focused()).and_then(|n| n.value()) +} + +/// Typing "60" over "100%" keeps the text while the field has focus (it used to snap back to +/// 100 after the first digit, giving 10060), applies it on Enter, and a value out of range is +/// clamped when the field loses focus. +#[test] +fn a_typed_tint_is_kept_while_typing_and_applied_on_enter_or_leaving_the_field() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"ids": [id], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = harness(app); + h.run_steps(3); + click_tint_field(&mut h); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(2); + for digit in ["6", "0"] { + h.event(egui::Event::Text(digit.into())); + h.run_steps(3); + } + assert_eq!(typed(&h).as_deref(), Some("60"), "the typed text stays while the field has focus"); + assert_eq!(fill_tint(h.state(), id), 1.0, "nothing is applied while typing"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!((fill_tint(h.state(), id) - 0.6).abs() < 1e-6, "Enter applies 60 %: {}", fill_tint(h.state(), id)); + assert_eq!(h.state().session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "C=100 M=0 Y=0 K=0"); + // Out of range: clamped to 100 % when focus moves away. + click_tint_field(&mut h); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("150".into())); + h.run_steps(3); + let away = egui::pos2(300.0, 310.0); + h.hover_at(away); + h.drag_at(away); + h.drop_at(away); + h.run_steps(3); + assert_eq!(fill_tint(h.state(), id), 1.0, "150 is clamped to 100 % when the field loses focus"); + // The slider still applies on release. + let track = h.get_by_role(Role::Slider).rect(); + h.hover_at(track.right_center()); + h.drag_at(track.right_center()); + h.run_steps(1); + h.hover_at(track.left_center()); + h.run_steps(1); + h.drop_at(track.left_center()); + h.run_steps(3); + assert_eq!(fill_tint(h.state(), id), 0.0, "dragging the slider to the left end applies 0 %"); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index baf9a1251..1feaaa6e1 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -13,6 +13,9 @@ pub mod styles; pub mod swatches; pub mod table; +#[cfg(test)] +mod color_panel_tests; + use designcraft_doc::{Content, StrokeAlign, WrapMode}; use designcraft_geom::Rect; use egui::vec2; diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index aea4e4acc..7ea1d8a0a 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -364,12 +364,25 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some(sw) = sw.as_ref().filter(|w| !w.hidden && !w.locked) { let cur = if stroke { stroke_tint } else { fill_tint }; let mut tint = cur * 100.0; + let mut commit = None; ui.horizontal(|ui| { ui.label("T"); - if ui.add(egui::Slider::new(&mut tint, 0.0..=100.0).suffix("%").integer()).drag_stopped() { - let _ = super::apply_swatch(app, stroke, &sw.name, Some(tint / 100.0)); + // The slider applies on release. The field keeps typed text while it has focus (the + // tint is read back from the document every frame) and applies it, clamped, on Enter + // or when it loses focus. + ui.spacing_mut().slider_width = (ui.available_width() - 60.0).max(60.0); + if ui.add(egui::Slider::new(&mut tint, 0.0..=100.0).integer().show_value(false)).drag_stopped() { + commit = Some(tint); + } + if let Some(v) = + crate::widgets::NumField::number("color_tint", Some(f64::from(cur) * 100.0), "%", 0).width(44.0).range(0.0, 100.0).show(ui) + { + commit = Some(v as f32); } }); + if let Some(v) = commit { + let _ = super::apply_swatch(app, stroke, &sw.name, Some(v / 100.0)); + } } let mut send: Option = None; match mode { From d52ef6b6b9ce9539134c109ba1d9df07dbab3cc5 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 00:18:55 +0200 Subject: [PATCH 094/128] A line is set when its baseline fits in the frame, as in InDesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign sets a text line when its baseline fits in the frame; the descenders may hang below the frame's bottom. DesignCraft required the baseline plus the descent to fit, so a frame sized to a heading's caps oversets: Zero2Graphic's magazine tutorial draws 2.7682 cm frames for 103 pt headings (control bar, 4:12–5:44), and all three were overset. The keep-lines IDML fixture's frames are now 236 pt tall, so they hold the same 19 lines under either rule. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 3 ++- crates/engine/src/tests.rs | 20 ++++++++++++++++++++ crates/engine/src/tests_idml.rs | 3 ++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..06fcc7e9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · A line of text is set when its baseline fits in the frame, as in InDesign: descenders may hang below the frame's bottom. A frame sized to the cap height of a big heading (103 pt in a 2.77 cm frame, as Zero2Graphic's magazine tutorial draws them) no longer oversets. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c6312e9f5..0f260ecf5 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1024,7 +1024,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let col_w = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)].width(); let reserve = notes.reserve(doc, cur.fi, cur.col, &line_notes, col_w, f, opts); let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts) } else { reserve }; - let fits = baseline + desc <= col.y1 - reserve + 0.01 && !capped; + // As in InDesign, a line is set when its baseline fits: its descenders may hang below the frame. + let fits = baseline <= col.y1 - reserve + 0.01 && !capped; if !fits { if cur.can_next_sub() { g0 = s; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 712dc06bc..01e15ee2d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -877,3 +877,23 @@ fn duplicating_a_page_to_an_index_is_one_undo_step() { assert!(s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 4})).is_err()); assert_eq!(widths(&s), [300.0, 400.0, 500.0]); } + +/// A line is set when its baseline fits in the frame; its descenders may hang below the frame, as +/// in InDesign. A 103 pt heading in a 78.5 pt frame (Cap Height first baseline) was overset. +#[test] +fn a_line_fits_when_its_baseline_does() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let overset = |s: &mut Session, height: f64| { + let r = s.execute("frame.create", &json!({"rect": [36, 36, 400, 36.0 + height], "content": "text", "text": "Gyp"})).unwrap(); + s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "firstBaseline": "capHeight", "inset": 0})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 3})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 103}})).unwrap(); + let st = s.execute("story.get", &json!({"story": r["story"]})).unwrap(); + !st["overset"].is_null() + }; + // Cap height of the default font at 103 pt is about 70 pt: the baseline fits in 78.5 pt, the + // descent (about 25 pt more) doesn't. + assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); + assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); +} diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 5e84532cf..c6ecc539c 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -107,7 +107,8 @@ fn keep_lines_idml(body_attrs: &str, lines: &[usize]) -> Vec { }) .collect(); let frame = |me: &str, x: f64, prev: &str, next: &str| { - let pts: String = [(x, 36.0), (x, 276.0), (x + 180.0, 276.0), (x + 180.0, 36.0)] + // 236 pt tall: 19 lines of 12 pt leading, whether or not the last line's descent must fit. + let pts: String = [(x, 36.0), (x, 272.0), (x + 180.0, 272.0), (x + 180.0, 36.0)] .iter() .map(|(x, y)| format!(r#""#)) .collect(); From 85d7c161613df14c53759c0094b4ed0b8d9c945f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 00:49:33 +0200 Subject: [PATCH 095/128] IDML: InDesign's default CJK leading model only with a Japanese composer InDesign writes LeadingModel="LeadingModelAkiBelow" on [No paragraph style] of every document, Western ones included, but only its Japanese composers use the leading model. DesignCraft applied it to every paragraph, so a 24 pt paragraph after a 32 pt one sat 34.53 pt below it instead of the Roman 32.8 (4 pt space after + 28.8 auto leading) in Montilla Design's Flex Layout exercise file, and its fitted captions came out overset. The import now drops that default when no part of the package applies a Japanese composer (HL Composer J / HL Single J). Leading models set on text, other styles, or in documents that use a Japanese composer are kept. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/idml/src/import.rs | 29 ++++++++++++++++++++++++++++- crates/idml/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..7daa06fa5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · IDML: the CJK leading model InDesign writes on [No paragraph style] of every document (Aki Below) only applies when the document uses a Japanese composer, as in InDesign; a Western document's paragraphs after a size change no longer sit lower than in InDesign (Montilla Design's Flex Layout file: its captions came out overset). No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..a0aa73d00 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -106,6 +106,7 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option Option bool { + const KEY: &[u8] = b"Composer=\""; + let mut rest = data; + while let Some(at) = rest.windows(KEY.len()).position(|w| w == KEY) { + let value = rest.get(at + KEY.len()..).unwrap_or_default(); + let end = value.iter().position(|b| *b == b'"').unwrap_or(value.len()); + let name = String::from_utf8_lossy(value.get(..end).unwrap_or_default()); + if name.ends_with(" J") || name.contains("Japanese") { + return true; + } + rest = value.get(end..).unwrap_or_default(); + } + false +} + /// A package part name in the form the lookup compares: InDesign and other IDML writers differ /// in percent-encoding, case, path separators and a leading `./` or `/`. fn normalize_part_name(name: &str) -> String { @@ -159,6 +177,8 @@ struct ItemCtx { struct Importer<'r> { read_link: &'r dyn Fn(&str) -> Option>, + /// The package applies a Japanese composer somewhere (see [`names_japanese_composer`]). + japanese_composer: bool, next_id: u64, settings: DocSettings, default_margins: Option<(Margins, Columns)>, @@ -275,6 +295,7 @@ impl<'r> Importer<'r> { styles.paragraph.iter_mut().for_each(|s| s.based_on = None); Importer { read_link, + japanese_composer: false, next_id: 0, settings: DocSettings::default(), default_margins: None, @@ -903,7 +924,13 @@ impl<'r> Importer<'r> { let based_on = if name == designcraft_doc::NO_PARA_STYLE { None } else { self.based_on(e, true) }; let next_style = e.get("NextStyle").map(|n| self.para_style_ref(n)).filter(|n| *n != name); let para = self.para_attrs(e); - let chars = self.char_attrs(e); + let mut chars = self.char_attrs(e); + // InDesign writes its CJK leading model (Aki Below) on [No paragraph style] of every + // document; only the Japanese composers use it, so a document set with the Paragraph or + // Single-line Composer spaces its lines baseline to baseline. + if name == designcraft_doc::NO_PARA_STYLE && !self.japanese_composer { + chars.leading_model = None; + } let s = ParagraphStyle { name: name.clone(), based_on, next_style, para, chars, shortcut: String::new() }; match self.styles.para_mut(&name) { Some(slot) => *slot = s, diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 57801e34f..7c9087da1 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2169,3 +2169,35 @@ fn frame_preference_property_color_resolves_for_frames_and_object_styles() { assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); } + +/// InDesign writes `LeadingModel="LeadingModelAkiBelow"` on [No paragraph style] of every +/// document, but only its Japanese composers use the leading model: with the Paragraph Composer, +/// a 24 pt paragraph after a 32 pt one sits a Roman 28.8 pt below it (Montilla Design's Flex Layout +/// file: frames fitted to that came out overset). +#[test] +fn the_default_cjk_leading_model_applies_only_with_a_japanese_composer() { + let styles = |composer: &str| { + STYLES.replace( + r#"Name="$ID/[No paragraph style]" PointSize="12">"#, + &format!(r#"Name="$ID/[No paragraph style]" PointSize="12" LeadingModel="LeadingModelAkiBelow" Composer="{composer}">"#), + ) + }; + let model = |composer: &str| { + let s = styles(composer); + let pkg = zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", &s), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ]); + let d = import_idml(&pkg).unwrap(); + d.styles.para(designcraft_doc::NO_PARA_STYLE).unwrap().chars.leading_model + }; + assert_eq!(model("HL Composer"), None, "the Paragraph Composer leads baseline to baseline"); + assert_eq!(model("HL Single"), None); + assert_eq!(model("$ID/HL Composer J"), Some(designcraft_doc::cjk::LeadingModel::AkiBelow)); + assert_eq!(model("$ID/HL Single J"), Some(designcraft_doc::cjk::LeadingModel::AkiBelow)); +} From 099fbd23ee2179dd460a3e9e23652fb063baa346 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 00:54:34 +0200 Subject: [PATCH 096/128] IDML: import and export ruler guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guides in an IDML page ( children of ) were dropped on import, and DesignCraft's own guides weren't written. Montilla Design's Flex Layout file keeps its 16 column guides on the B-Parent page; none came in. Import: Location is measured from View Preferences › Ruler Origin (the spread's top left, the page's, or the spine); Orientation, FitToPage (off: a spread guide), Locked and ItemLayer are kept. Export writes each page's guides under its in spread-origin coordinates, which is the ruler origin DesignCraft writes. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/idml/src/export.rs | 20 +++++++++++++++ crates/idml/src/import.rs | 52 ++++++++++++++++++++++++++++++++++++--- crates/idml/src/tests.rs | 51 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..bad2c9d04 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · IDML: ruler guides import and export (they were dropped). Each page's guides come in measured from the document's ruler origin (spread, page or spine), with Fit to Page, Locked and their layer, parent pages' included, and go out again under their pages. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0b9435c71..1a5c9df77 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1667,6 +1667,26 @@ impl<'a> Ex<'a> { .attr("GeometricBounds", format!("0 0 {} {}", num(pg.height), num(pg.width))) .attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy))) .child(margin_el(pg)); + let mut pe = pe; + // Ruler guides, measured from the spread's top left (RulerOrigin is SpreadOrigin). + let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min); + let left = if left.is_finite() { left } else { 0.0 }; + for g in &pg.guides { + let vertical = g.orientation == designcraft_doc::Orientation::Vertical; + let mut ge = El::new("Guide") + .attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count())) + .attr("Orientation", if vertical { "Vertical" } else { "Horizontal" }) + .attr("Location", num(if vertical { g.position - left } else { g.position })) + .attr("FitToPage", bool_s(!g.spread)) + .attr("ViewThreshold", "5") + .attr("Locked", bool_s(g.locked)) + .attr("PageIndex", (first_page + i).to_string()) + .attr("GuideType", "Ruler"); + if let Some(l) = g.layer { + ge.set("ItemLayer", uid(l.0)); + } + pe.push(ge); + } el.push(pe); } let origin = Affine::translate((-ox, -oy)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..7dd5867b6 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -9,8 +9,8 @@ use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, use designcraft_doc::{ Asset, AssetId, BaselineGrid, CellRange, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document, DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle, - Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, SpreadId, - Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st, + Orientation, Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, + SpreadId, Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st, }; use designcraft_geom::corners::{Corner, CornerOptions}; use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath}; @@ -116,6 +116,14 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option String { @@ -159,6 +167,8 @@ struct ItemCtx { struct Importer<'r> { read_link: &'r dyn Fn(&str) -> Option>, + /// View Preferences › Ruler Origin: where a guide's Location is measured from. + ruler_origin: RulerOrigin, next_id: u64, settings: DocSettings, default_margins: Option<(Margins, Columns)>, @@ -275,6 +285,7 @@ impl<'r> Importer<'r> { styles.paragraph.iter_mut().for_each(|s| s.based_on = None); Importer { read_link, + ruler_origin: RulerOrigin::Spread, next_id: 0, settings: DocSettings::default(), default_margins: None, @@ -362,6 +373,11 @@ impl<'r> Importer<'r> { if let Some(v) = e.num("CursorKeyIncrement") { self.settings.keyboard_increment = v; } + self.ruler_origin = match e.get("RulerOrigin") { + Some("PageOrigin") => RulerOrigin::Page, + Some("SpineOrigin") => RulerOrigin::Spine, + _ => RulerOrigin::Spread, + }; } "GridPreference" => self.grid_prefs(e), "PasteboardPreference" => { @@ -1964,6 +1980,33 @@ impl<'r> Importer<'r> { // ---------- spreads and items ---------- + /// Ruler guides of a `` (its `` children), `page` its rectangle in our spread + /// coordinates. A guide's Location is measured from the ruler origin: the spread's top left, the + /// page's, or the spine. Fit to Page off: the guide crosses the spread. + fn guides_of(&self, p: &El, page: Rect, spine: f64) -> Vec { + p.find_all("Guide") + .filter_map(|g| { + let at = g.num("Location").filter(|v| v.is_finite() && v.abs() < 1e7)?; + let orientation = if g.get("Orientation") == Some("Horizontal") { Orientation::Horizontal } else { Orientation::Vertical }; + let position = match (orientation, self.ruler_origin) { + (Orientation::Horizontal, RulerOrigin::Page) => page.y0 + at, + (Orientation::Horizontal, _) => at, + (Orientation::Vertical, RulerOrigin::Page) => page.x0 + at, + (Orientation::Vertical, RulerOrigin::Spine) => spine + at, + (Orientation::Vertical, RulerOrigin::Spread) => at, + }; + Some(Guide { + orientation, + position, + spread: g.get("FitToPage") == Some("false"), + locked: g.get("Locked") == Some("true"), + layer: g.get("ItemLayer").and_then(|l| self.layer_ids.get(l)).copied(), + liquid: false, + }) + }) + .collect() + } + fn spread(&mut self, e: &El, id: SpreadId, master: bool) -> Spread { let facing = self.settings.facing_pages; // Page rectangles in IDML spread coordinates. @@ -1981,6 +2024,9 @@ impl<'r> Importer<'r> { let min_y = pages.iter().map(|(r, _)| r.y0).fold(f64::INFINITY, f64::min); let (min_x, min_y) = if min_x.is_finite() { (min_x, min_y) } else { (0.0, 0.0) }; let shift = Affine::translate((-min_x, -min_y)); + // The spine: between a left and a right page (else the spread's left edge). + let spine = pages.iter().map(|(r, _)| r.x0 - min_x).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min); + let spine = if spine.is_finite() && facing { spine } else { 0.0 }; let show = e.get("ShowMasterItems") != Some("false"); let first_abs: usize = self.spreads.iter().map(|s| s.pages.len()).sum(); let mut out_pages = Vec::new(); @@ -2013,7 +2059,7 @@ impl<'r> Importer<'r> { parent, side, overridden: vec![], - guides: Vec::::new(), + guides: self.guides_of(p, Rect::new(r.x0 - min_x, r.y0 - min_y, r.x1 - min_x, r.y1 - min_y), spine), show_parent_items: show, liquid: Default::default(), transition: None, diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 57801e34f..934281734 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2169,3 +2169,54 @@ fn frame_preference_property_color_resolves_for_frames_and_object_styles() { assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); } + +/// A two-page spread with ruler guides on its pages (and one on the parent), in `origin` ruler +/// coordinates. +fn guides_package(origin: &str) -> Vec { + let prefs = PREFS.replace("", &format!(r#""#)); + let spread = SPREAD.replace( + r#""#, + r#" + + + "#, + ); + let master = MASTER.replace( + r#""#, + r#""#, + ); + zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", &prefs), + ("MasterSpreads/MasterSpread_m1.xml", &master), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ]) +} + +/// Ruler guides come in from IDML (they were dropped): on their page, measured from the ruler +/// origin, with Fit to Page, Locked and their layer; parent pages' too. They go out again. +#[test] +fn ruler_guides_import_and_round_trip() { + use designcraft_doc::Orientation::{Horizontal, Vertical}; + let guides = |d: &Document| { + let p = &d.spreads[0].pages[1]; + p.guides.iter().map(|g| (g.orientation, g.position, g.spread, g.locked, g.layer.is_some())).collect::>() + }; + // The second page starts at x 500 in our spread coordinates. + let d = import_idml(&guides_package("PageOrigin")).unwrap(); + assert_eq!(guides(&d), [(Vertical, 540.0, false, true, true), (Horizontal, 100.0, true, false, false)]); + let d = import_idml(&guides_package("SpineOrigin")).unwrap(); + assert_eq!(guides(&d)[0].1, 540.0, "the spine is the second page's left edge"); + let d = import_idml(&guides_package("SpreadOrigin")).unwrap(); + assert_eq!(guides(&d)[0].1, 40.0); + assert_eq!(d.parents[0].pages[0].guides.len(), 1, "a parent page's guide"); + assert_eq!(d.parents[0].pages[0].guides[0].position, 84.0); + // Round trip: written under each page, read back where they were. + let d = import_idml(&guides_package("PageOrigin")).unwrap(); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(guides(&back), guides(&d)); + assert_eq!(back.parents[0].pages[0].guides.len(), 1); +} From 6fe0770f476bc1977681098852ec3157f6c956bd Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 01:20:50 +0200 Subject: [PATCH 097/128] Flex Layout: containers that lay out their objects, as InDesign 2026's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Flex Layout container is a frame holding its objects as nested items (as Edit › Paste Into makes), with a `flex` record: direction, wrap, justification, cross-axis alignment, Fixed/Auto width and height, padding and horizontal/vertical spacing. designcraft_doc::flex lays the objects out (a Flexbox subset: lines, justification, alignment, Auto sizing), and the engine runs it after every edit, so the container keeps its objects in place whatever changes. Commands: flex.add (Add to Flex Layout; detects aligned objects: their direction, Auto size, Space Between at their own spacing, as InDesign does), flex.convert (a group or rectangle frame), flex.remove (objects stay, grouped), flex.set (every panel setting, plus the alignment grid cell), flex.reorder. Resizing a container by its handles or W/H changes its box and makes that side Fixed instead of scaling its objects; dragging an object inside one drops it in the nearest place. UI: Object › Flex Layout (Add to, Convert to, Remove, Show Flex Controls), a Flex Layout panel (Window › Panels) laid out as InDesign's (Create Flex Layout; alignment grid, W/H with Auto/Fixed; justification menu, four directions, wrap; padding with the link button; spacing, greyed as InDesign does when the justification works it out or nothing wraps), and the same section in Properties. Icons are new drawings. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/flex.rs | 341 ++++++++++++++ crates/doc/src/item.rs | 4 + crates/doc/src/lib.rs | 2 + crates/engine/src/cmd/flex.rs | 571 ++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/object.rs | 29 +- crates/engine/src/lib.rs | 1 + crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/i18n.rs | 69 +++ crates/ui-egui/src/i18n/ar.rs | 22 + crates/ui-egui/src/i18n/it.rs | 24 +- crates/ui-egui/src/i18n/ja.rs | 22 + crates/ui-egui/src/i18n/pt_br.rs | 22 + crates/ui-egui/src/i18n/uk.rs | 22 + crates/ui-egui/src/icons.rs | 50 +++ crates/ui-egui/src/menus.rs | 7 + crates/ui-egui/src/panels/properties.rs | 282 ++++++++++++ docs/gaps.md | 2 +- docs/parity-checklist.md | 5 +- docs/target-app-parity.md | 1 + 21 files changed, 1476 insertions(+), 5 deletions(-) create mode 100644 crates/doc/src/flex.rs create mode 100644 crates/engine/src/cmd/flex.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..bfb80c482 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Flex Layout (InDesign 2026): Object › Flex Layout (Add to, Convert to, Remove) and a Flex Layout panel laid out as InDesign's, also in Properties. A container lays out its objects by direction, wrap, justification, the alignment grid, Fixed/Auto width and height, padding and spacing, after every edit; Add detects aligned objects (direction, Auto size, Space Between at their spacing); resizing a container reflows its objects instead of scaling them; dragging an object in one reorders it. Montilla Design's tutorial replays step for step. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/doc/src/flex.rs b/crates/doc/src/flex.rs new file mode 100644 index 000000000..2b76be474 --- /dev/null +++ b/crates/doc/src/flex.rs @@ -0,0 +1,341 @@ +//! Flex Layout (InDesign 2026): a frame whose objects are placed in rows or columns by direction, +//! wrap, justification, alignment, padding and spacing, like CSS Flexbox. The container is a frame +//! holding its objects as nested items (as Edit › Paste Into does), so it draws its fill, clips its +//! objects and strokes like any frame; [`layout`] moves the objects to where the settings put them. + +use designcraft_geom::{Affine, Rect, Vec2, shapes}; +use serde::{Deserialize, Serialize}; + +use crate::{Content, Item}; + +/// The main axis and the way along it (Flex Layout panel › Direction). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FlexDirection { + #[default] + Row, + RowReverse, + Column, + ColumnReverse, +} + +impl FlexDirection { + pub fn is_row(self) -> bool { + matches!(self, FlexDirection::Row | FlexDirection::RowReverse) + } + pub fn is_reverse(self) -> bool { + matches!(self, FlexDirection::RowReverse | FlexDirection::ColumnReverse) + } +} + +/// How the objects of a row (or column) share its length (Flex Layout panel › Justification). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FlexJustify { + #[default] + FlexStart, + Center, + FlexEnd, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +impl FlexJustify { + /// The space modes work the spacing out themselves (the panel shows it, greyed). + pub fn distributes(self) -> bool { + matches!(self, FlexJustify::SpaceBetween | FlexJustify::SpaceAround | FlexJustify::SpaceEvenly) + } +} + +/// Where rows sit across the container, and objects across their row (the alignment grid's other +/// axis). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FlexAlign { + #[default] + Start, + Center, + End, +} + +/// Width or height of a container: Fixed, or Auto (it grows and shrinks with its objects). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FlexSize { + #[default] + Fixed, + Auto, +} + +/// A Flex Layout container's settings. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct FlexLayout { + pub direction: FlexDirection, + /// Wrap: objects that don't fit start a new row (column). + pub wrap: bool, + pub justify: FlexJustify, + pub align: FlexAlign, + pub width: FlexSize, + pub height: FlexSize, + /// Padding inside the container: top, left, bottom, right. + pub padding: [f64; 4], + /// Spacing between objects horizontally and vertically. + pub gap_h: f64, + pub gap_v: f64, +} + +impl Default for FlexLayout { + fn default() -> Self { + FlexLayout { + direction: FlexDirection::Row, + wrap: false, + justify: FlexJustify::FlexStart, + align: FlexAlign::Start, + width: FlexSize::Fixed, + height: FlexSize::Fixed, + padding: [0.0; 4], + gap_h: 0.0, + gap_v: 0.0, + } + } +} + +impl FlexLayout { + /// Spacing along the main axis and across it. + pub fn gaps(&self) -> (f64, f64) { + if self.direction.is_row() { (self.gap_h, self.gap_v) } else { (self.gap_v, self.gap_h) } + } +} + +/// Where [`arrange`] puts things: the container's rectangle and each object's offset, in the +/// container's inner space. +#[derive(Clone, Debug, PartialEq)] +pub struct Arrangement { + pub rect: Rect, + pub moves: Vec, + /// The spacing the main axis ends up with between the first row's objects (what the panel shows + /// for a space mode). + pub main_gap: f64, +} + +/// Lay out objects of the given bounds in `rect` (both in the container's inner space). +pub fn arrange(f: &FlexLayout, rect: Rect, bounds: &[Rect]) -> Arrangement { + let row = f.direction.is_row(); + let fin = |v: f64| if v.is_finite() { v.max(0.0) } else { 0.0 }; + let [pt, pl, pb, pr] = f.padding.map(fin); + let (gap_main, gap_cross) = f.gaps(); + let (gap_main, gap_cross) = (fin(gap_main), fin(gap_cross)); + let size = |b: &Rect| if row { (b.width(), b.height()) } else { (b.height(), b.width()) }; + let (main_auto, cross_auto) = + if row { (f.width == FlexSize::Auto, f.height == FlexSize::Auto) } else { (f.height == FlexSize::Auto, f.width == FlexSize::Auto) }; + let (pad_main_start, pad_main_end, pad_cross_start, pad_cross_end) = if row { (pl, pr, pt, pb) } else { (pt, pb, pl, pr) }; + let (rect_main, rect_cross) = if row { (rect.width(), rect.height()) } else { (rect.height(), rect.width()) }; + let mut main_len = (rect_main - pad_main_start - pad_main_end).max(0.0); + // Rows: the objects in each, broken where the next one wouldn't fit (Wrap on a fixed length). + let mut lines: Vec> = Vec::new(); + let mut used = 0.0; + for (i, b) in bounds.iter().enumerate() { + let (m, _) = size(b); + let breaks = f.wrap && !main_auto && lines.last().is_some_and(|l| !l.is_empty() && used + gap_main + m > main_len + 1e-6); + match lines.last_mut() { + Some(l) if !breaks => { + used += if l.is_empty() { m } else { gap_main + m }; + l.push(i); + } + _ => { + lines.push(vec![i]); + used = m; + } + } + } + let line_main = |l: &[usize]| l.iter().map(|&i| size(&bounds[i]).0).sum::() + gap_main * l.len().saturating_sub(1) as f64; + let line_cross = |l: &[usize]| l.iter().map(|&i| size(&bounds[i]).1).fold(0.0, f64::max); + if main_auto { + main_len = lines.iter().map(|l| line_main(l)).fold(0.0, f64::max); + } + let content_cross = lines.iter().map(|l| line_cross(l)).sum::() + gap_cross * lines.len().saturating_sub(1) as f64; + let cross_len = if cross_auto { content_cross } else { (rect_cross - pad_cross_start - pad_cross_end).max(0.0) }; + // The container: Auto sides fit the objects, from its top left. + let (w, h) = if row { + (main_len + pad_main_start + pad_main_end, cross_len + pad_cross_start + pad_cross_end) + } else { + (cross_len + pad_cross_start + pad_cross_end, main_len + pad_main_start + pad_main_end) + }; + let out_rect = Rect::new( + rect.x0, + rect.y0, + rect.x0 + if (row && main_auto) || (!row && cross_auto) { w } else { rect.width() }, + rect.y0 + if (!row && main_auto) || (row && cross_auto) { h } else { rect.height() }, + ); + let shift = |free: f64| match f.align { + FlexAlign::Start => 0.0, + FlexAlign::Center => free / 2.0, + FlexAlign::End => free, + }; + let mut moves = vec![Vec2::ZERO; bounds.len()]; + let mut cross_at = shift(cross_len - content_cross); + let mut main_gap = gap_main; + for (li, l) in lines.iter().enumerate() { + let lc = line_cross(l); + let free = main_len - line_main(l); + let n = l.len() as f64; + let (start, gap) = match f.justify { + _ if free <= 0.0 => (0.0, gap_main), + FlexJustify::FlexStart => (0.0, gap_main), + FlexJustify::Center => (free / 2.0, gap_main), + FlexJustify::FlexEnd => (free, gap_main), + FlexJustify::SpaceBetween if l.len() > 1 => (0.0, gap_main + free / (n - 1.0)), + FlexJustify::SpaceBetween => (0.0, gap_main), + FlexJustify::SpaceAround => (free / (2.0 * n), gap_main + free / n), + FlexJustify::SpaceEvenly => (free / (n + 1.0), gap_main + free / (n + 1.0)), + }; + if li == 0 { + main_gap = gap; + } + let mut at = start; + for &i in l { + let (m, c) = size(&bounds[i]); + // Reverse directions run from the end. + let main = if f.direction.is_reverse() { main_len - at - m } else { at }; + let cross = cross_at + shift(lc - c); + let (x, y) = if row { + (out_rect.x0 + pad_main_start + main, out_rect.y0 + pad_cross_start + cross) + } else { + (out_rect.x0 + pad_cross_start + cross, out_rect.y0 + pad_main_start + main) + }; + moves[i] = Vec2::new(x - bounds[i].x0, y - bounds[i].y0); + at += m + gap; + } + cross_at += lc + gap_cross; + } + Arrangement { rect: out_rect, moves, main_gap } +} + +/// Lay out a container's objects (and size it, for Auto sides). Returns whether anything moved. +pub fn layout(it: &mut Item) -> bool { + let Some(f) = it.flex.clone() else { return false }; + let Content::Group { items } = &it.content else { return false }; + let rect = it.inner_bounds(); + let bounds: Vec = items.iter().map(|c| c.bounds()).collect(); + let a = arrange(&f, rect, &bounds); + let mut changed = false; + if (a.rect.width() - rect.width()).abs() > 1e-6 || (a.rect.height() - rect.height()).abs() > 1e-6 { + it.path = shapes::rectangle(a.rect); + changed = true; + } + if let Content::Group { items } = &mut it.content { + for (c, d) in items.iter_mut().zip(&a.moves) { + if d.hypot() > 1e-6 { + let c = std::sync::Arc::make_mut(c); + c.xf = Affine::translate(*d) * c.xf; + changed = true; + } + } + } + changed +} + +/// Lay out every container among `items` and the objects nested in them, inner containers first +/// (an Auto container's size is its parent's object size). Only changed items are copied. +pub fn layout_all(items: &mut [std::sync::Arc]) { + for a in items.iter_mut() { + if !contains_flex(a) { + continue; + } + let it = std::sync::Arc::make_mut(a); + if let Content::Group { items } = &mut it.content { + layout_all(items); + } + layout(it); + } +} + +/// Is `it` a container, or does it hold one? +pub fn contains_flex(it: &Item) -> bool { + it.flex.is_some() || matches!(&it.content, Content::Group { items } if items.iter().any(|c| contains_flex(c))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(x0: f64, y0: f64, w: f64, h: f64) -> Rect { + Rect::new(x0, y0, x0 + w, y0 + h) + } + + fn placed(f: &FlexLayout, rect: Rect, bounds: &[Rect]) -> (Rect, Vec<(f64, f64)>) { + let a = arrange(f, rect, bounds); + (a.rect, bounds.iter().zip(&a.moves).map(|(b, m)| ((b.x0 + m.x).round(), (b.y0 + m.y).round())).collect()) + } + + #[test] + fn a_row_justifies_and_aligns() { + let boxes = [r(500.0, 500.0, 100.0, 50.0), r(0.0, 0.0, 100.0, 80.0), r(0.0, 0.0, 100.0, 50.0)]; + let mut f = FlexLayout { gap_h: 10.0, ..Default::default() }; + let rect = r(0.0, 0.0, 400.0, 100.0); + assert_eq!(placed(&f, rect, &boxes).1, [(0.0, 0.0), (110.0, 0.0), (220.0, 0.0)]); + f.justify = FlexJustify::FlexEnd; + assert_eq!(placed(&f, rect, &boxes).1[0], (80.0, 0.0)); + f.justify = FlexJustify::Center; + assert_eq!(placed(&f, rect, &boxes).1[0], (40.0, 0.0)); + f.justify = FlexJustify::SpaceBetween; + assert_eq!(placed(&f, rect, &boxes).1, [(0.0, 0.0), (150.0, 0.0), (300.0, 0.0)]); + assert_eq!(arrange(&f, rect, &boxes).main_gap, 50.0); + f.justify = FlexJustify::SpaceEvenly; + assert_eq!(placed(&f, rect, &boxes).1[0], (20.0, 0.0)); + f.justify = FlexJustify::SpaceAround; + assert_eq!(placed(&f, rect, &boxes).1[0], (13.0, 0.0)); + // Across: the 80 tall row; the 50 tall boxes centre or end in it, and the row in the container. + f.justify = FlexJustify::FlexStart; + f.align = FlexAlign::Center; + assert_eq!(placed(&f, rect, &boxes).1, [(0.0, 25.0), (110.0, 10.0), (220.0, 25.0)]); + f.align = FlexAlign::End; + assert_eq!(placed(&f, rect, &boxes).1[1], (110.0, 20.0)); + f.direction = FlexDirection::RowReverse; + f.align = FlexAlign::Start; + assert_eq!(placed(&f, rect, &boxes).1, [(300.0, 0.0), (190.0, 0.0), (80.0, 0.0)]); + } + + /// The video's second layout: a column that wraps into two, 56 apart, 38 between objects, + /// centred in the container both ways. + #[test] + fn a_wrapped_column_centres() { + let boxes = [r(0.0, 0.0, 300.0, 400.0); 4]; + let f = FlexLayout { + direction: FlexDirection::Column, + wrap: true, + justify: FlexJustify::Center, + align: FlexAlign::Center, + gap_h: 56.0, + gap_v: 38.0, + ..Default::default() + }; + let (rect, at) = placed(&f, r(0.0, 0.0, 868.0, 912.0), &boxes); + assert_eq!(rect, r(0.0, 0.0, 868.0, 912.0)); + // Each column 400 + 38 + 400 = 838 tall, centred in 912; the two 300 wide columns and the + // 56 between them (656) centred in 868. + assert_eq!(at, [(106.0, 37.0), (106.0, 475.0), (462.0, 37.0), (462.0, 475.0)]); + } + + #[test] + fn auto_sides_fit_the_objects_and_padding() { + let boxes = [r(0.0, 0.0, 100.0, 50.0), r(0.0, 0.0, 60.0, 70.0)]; + let f = FlexLayout { width: FlexSize::Auto, height: FlexSize::Auto, gap_h: 20.0, padding: [5.0, 10.0, 15.0, 30.0], ..Default::default() }; + let (rect, at) = placed(&f, r(40.0, 40.0, 10.0, 10.0), &boxes); + assert_eq!(rect, Rect::new(40.0, 40.0, 40.0 + 10.0 + 180.0 + 30.0, 40.0 + 5.0 + 70.0 + 15.0)); + assert_eq!(at, [(50.0, 45.0), (170.0, 45.0)]); + // Auto ignores Wrap: one row. + let wrapped = FlexLayout { wrap: true, ..f }; + assert_eq!(placed(&wrapped, r(40.0, 40.0, 10.0, 10.0), &boxes).1, at); + } + + #[test] + fn hostile_numbers_dont_break_the_layout() { + let f = FlexLayout { gap_h: f64::NAN, padding: [f64::INFINITY, -5.0, 0.0, 0.0], wrap: true, ..Default::default() }; + let a = arrange(&f, r(0.0, 0.0, 10.0, 10.0), &[r(0.0, 0.0, 50.0, 50.0), r(0.0, 0.0, 50.0, 50.0)]); + assert!(a.moves.iter().all(|m| m.x.is_finite() && m.y.is_finite())); + assert!(arrange(&f, r(0.0, 0.0, 10.0, 10.0), &[]).moves.is_empty()); + } +} diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 70d56cefe..966039c5f 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1016,6 +1016,9 @@ pub struct Item { pub isolate: bool, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub knockout: bool, + /// Flex Layout: this frame lays out the objects nested in it (see [`crate::flex`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flex: Option, } fn identity() -> Affine { @@ -1061,6 +1064,7 @@ impl Item { active_state: 0, isolate: false, knockout: false, + flex: None, } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 49a81830c..c0fdc8fe2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -19,6 +19,7 @@ pub mod cjk; pub mod datamerge; mod edit; pub mod endnotes; +pub mod flex; pub mod ids; pub mod index; pub mod item; @@ -47,6 +48,7 @@ pub use designcraft_color as color; pub use designcraft_geom as geom; pub use edit::{ItemLoc, ItemPath, SpreadRef, item_hit as edit_hit}; pub use endnotes::{ENDNOTE_REF, EditorialNote, EndnoteOptions, NOTE_MARK}; +pub use flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize}; pub use ids::*; pub use index::{INDEX_MARK, IndexRef}; pub use item::*; diff --git a/crates/engine/src/cmd/flex.rs b/crates/engine/src/cmd/flex.rs new file mode 100644 index 000000000..bdb7f73e8 --- /dev/null +++ b/crates/engine/src/cmd/flex.rs @@ -0,0 +1,571 @@ +//! Flex Layout: Object › Flex Layout and the Flex Layout panel. A container is a frame whose +//! nested objects [`designcraft_doc::flex::layout`] places; [`sync`] lays every container out +//! after each edit, so moving, resizing or editing an object inside one reflows it. + +use std::sync::Arc; + +use designcraft_doc::flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize}; +use designcraft_doc::{Content, Document, Item, ItemId, Selection, Shape}; +use designcraft_geom::{Rect, shapes}; +use serde_json::{Value, json}; + +use super::object::RemoveKeep; +use super::{CommandSpec, bad, cmd, has_doc, has_selection, id_param, targets}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "flex.add", + "Add to Flex Layout", + ["Object", "Flex Layout"], + None, + "{ids?} — the selected objects (top level, one spread) in a new Flex Layout container where they are; aligned objects get that direction, Auto size and Space Between, others a wrapping row of their width. One group or empty rectangle frame is converted instead (flex.convert) → {id}", + has_selection, + add + ), + cmd!( + "flex.convert", + "Convert to Flex Layout", + ["Object", "Flex Layout"], + None, + "{ids?} — a group (its objects) or a rectangle frame (with what is pasted into it) becomes a Flex Layout container → {id}", + has_selection, + convert + ), + cmd!( + "flex.remove", + "Remove Flex Layout", + ["Object", "Flex Layout"], + None, + "{ids?} — the container goes; its objects stay where they are, grouped when there are several (a container with its own fill or stroke stays as a frame holding them) → {ids}", + is_container, + remove + ), + cmd!( + "flex.set", + "Flex Layout Options", + [], + None, + "{ids?, direction?: row|rowReverse|column|columnReverse, wrap?: bool, justify?: flexStart|center|flexEnd|spaceBetween|spaceAround|spaceEvenly, align?: start|center|end (across), cell?: 0..8 (the alignment grid, row by row from the top left: sets justification and alignment by direction), width?: auto|fixed|number, height?: auto|fixed|number, padding?: number|[top, left, bottom, right], gapH?, gapV?} → {changed}", + is_container, + set + ), + cmd!( + "flex.reorder", + "Reorder in Flex Layout", + [], + None, + "{id (an object in a container), to?: index | by?: ±n} — its place in the container's order → {index}", + has_doc, + reorder + ), + ] +} + +fn is_container(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document")?; + if st.selection.items.iter().any(|i| st.doc.item(*i).is_some_and(|it| it.flex.is_some())) { + Ok(()) + } else { + Err("select a Flex Layout container".into()) + } +} + +/// Lay out every container (called after each edit). Spreads without one aren't copied. +pub(crate) fn sync(d: &mut Document) { + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + if sp.items.iter().any(|i| designcraft_doc::flex::contains_flex(i)) { + designcraft_doc::flex::layout_all(&mut Arc::make_mut(sp).items); + } + } +} + +/// InDesign's detection when objects become a container: objects in a row (or column) keep it, +/// with Auto size and Space Between at their own spacing; others wrap in a row of their width. +fn detect(bounds: &[Rect]) -> FlexLayout { + let tol = 0.5; + let in_line = |row: bool| { + let mut v: Vec<&Rect> = bounds.iter().collect(); + v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) }); + let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol }); + let gaps: Vec = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect(); + (apart && bounds.len() > 1).then(|| gaps.iter().sum::() / gaps.len().max(1) as f64) + }; + let same = |f: &dyn Fn(&Rect) -> f64| bounds.windows(2).all(|w| (f(&w[0]) - f(&w[1])).abs() <= tol); + let align = |row: bool| { + let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) = + if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) }; + if same(a) { + FlexAlign::Start + } else if same(c) { + FlexAlign::Center + } else if same(e) { + FlexAlign::End + } else { + FlexAlign::Start + } + }; + let auto = |gap: f64, row: bool| FlexLayout { + direction: if row { FlexDirection::Row } else { FlexDirection::Column }, + justify: FlexJustify::SpaceBetween, + align: align(row), + width: FlexSize::Auto, + height: FlexSize::Auto, + gap_h: if row { gap.max(0.0) } else { 0.0 }, + gap_v: if row { 0.0 } else { gap.max(0.0) }, + ..Default::default() + }; + match (in_line(true), in_line(false)) { + (Some(gap), _) => auto(gap, true), + (None, Some(gap)) => auto(gap, false), + _ => FlexLayout { wrap: true, height: FlexSize::Auto, ..Default::default() }, + } +} + +fn add(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + if let [one] = ids.as_slice() + && s.doc()?.doc.item(*one).is_some_and(convertible) + { + return convert(s, p); + } + if ids.is_empty() { + return Err(bad("flex.add", "select the objects to put in a Flex Layout")); + } + s.edit(|d, sel| { + let sr = d.find(ids[0]).map(|l| l.spread).ok_or(designcraft_doc::DocError::NoItem(ids[0]))?; + let order: Vec = d.spread(sr).map(|sp| sp.items.iter().map(|i| i.id).filter(|i| ids.contains(i)).collect()).unwrap_or_default(); + if order.len() != ids.len() { + return Err(bad("flex.add", "select objects on one spread, not inside a group or frame")); + } + let front = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| Some(&i.id) == order.last())).unwrap_or(0); + let layer = d.item(order[0]).map(|i| i.layer).unwrap_or_default(); + let mut kids: Vec = Vec::new(); + for id in &order { + kids.push(d.remove_item_keep_story(*id)?); + } + // Their order in the container: along the line they are in, else top to bottom, left to right. + let bounds: Vec = kids.iter().map(Item::bounds).collect(); + let f = detect(&bounds); + // A row reads left to right (wrapped: row by row); a column top to bottom (wrapped: column by + // column). + let key = |r: &Rect| if f.direction.is_row() != f.wrap { (r.x0, r.y0) } else { (r.y0, r.x0) }; + kids.sort_by(|a, b| { + let (ka, kb) = (key(&a.bounds()), key(&b.bounds())); + ka.0.total_cmp(&kb.0).then(ka.1.total_cmp(&kb.1)) + }); + let rect = bounds.iter().copied().reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + let cid = ItemId(d.alloc()); + let mut c = Item::new(cid, layer, Shape::Rectangle, shapes::rectangle(rect)); + c.content = Content::Group { items: kids.into_iter().map(Arc::new).collect() }; + c.flex = Some(f); + d.insert_item(sr, c, Some(front + 1 - order.len()))?; + *sel = Selection::items(vec![cid]); + Ok(json!({"id": cid.0})) + }) +} + +/// A group, or a rectangle frame holding nothing or pasted-in objects. +fn convertible(it: &Item) -> bool { + it.flex.is_none() && (it.is_group() || it.shape == Shape::Rectangle && matches!(it.content, Content::Unassigned | Content::Group { .. })) +} + +fn convert(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let [id] = ids.as_slice() else { return Err(bad("flex.convert", "select one group or rectangle frame")) }; + let id = *id; + s.edit(|d, sel| { + let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?.clone(); + if !convertible(&it) { + return Err(bad("flex.convert", "select one group or rectangle frame")); + } + let kids: Vec> = match &it.content { + Content::Group { items } => items.clone(), + _ => Vec::new(), + }; + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + if it.is_group() { + // The group's box becomes the container's, in its own space. + let rect = kids.iter().map(|k| k.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + it.shape = Shape::Rectangle; + it.path = shapes::rectangle(rect); + } + let bounds: Vec = kids.iter().map(|k| k.bounds()).collect(); + it.flex = Some(if bounds.is_empty() { FlexLayout::default() } else { detect(&bounds) }); + it.content = Content::Group { items: kids }; + *sel = Selection::items(vec![id]); + Ok(json!({"id": id.0})) + }) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + s.edit(|d, sel| { + let mut out = Vec::new(); + for id in &ids { + let Some(loc) = d.find(*id) else { continue }; + let Some(c) = d.item(*id).cloned() else { continue }; + if c.flex.is_none() { + continue; + } + let kids = match &c.content { + Content::Group { items } => items.clone(), + _ => Vec::new(), + }; + // A frame with its own look stays one, holding its objects. + if !c.fill.is_none() || !c.stroke.is_none() || kids.is_empty() { + if let Some(it) = d.item_mut(*id) { + it.flex = None; + } + out.push(*id); + continue; + } + if loc.path.len() != 1 { + return Err(bad("flex.remove", "a Flex Layout inside a group or another container keeps its objects; remove the outer one first")); + } + let Some(sp) = d.spread_mut(loc.spread) else { continue }; + sp.items.remove(loc.path[0]); + let new = if let [one] = kids.as_slice() { + let mut k = (**one).clone(); + k.xf = c.xf * k.xf; + k + } else { + let mut g = Item::new(c.id, c.layer, Shape::Group, designcraft_geom::PathData::default()); + g.xf = c.xf; + g.content = Content::Group { items: kids }; + g + }; + out.push(new.id); + sp.items.insert(loc.path[0], Arc::new(new)); + } + *sel = Selection::items(out.clone()); + Ok(json!({"ids": out.iter().map(|i| i.0).collect::>()})) + }) +} + +fn parse(p: &Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|_| bad("flex.set", format!("`{key}`: unknown value {v}"))), + } +} + +fn set(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let direction: Option = parse(p, "direction")?; + let justify: Option = parse(p, "justify")?; + let align: Option = parse(p, "align")?; + let cell = p.get("cell").and_then(Value::as_u64); + if cell.is_some_and(|c| c > 8) { + return Err(bad("flex.set", "`cell` is 0..8")); + } + let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 100_000.0)); + let padding: Option<[f64; 4]> = match p.get("padding") { + None => None, + Some(Value::Number(n)) => n.as_f64().filter(|v| v.is_finite()).map(|v| [v.clamp(0.0, 100_000.0); 4]), + Some(v) => { + let a: Vec = serde_json::from_value(v.clone()).map_err(|_| bad("flex.set", "`padding`: a number or [top, left, bottom, right]"))?; + let [t, l, b, r] = a.as_slice() else { return Err(bad("flex.set", "`padding`: a number or [top, left, bottom, right]")) }; + Some([*t, *l, *b, *r].map(|v| if v.is_finite() { v.clamp(0.0, 100_000.0) } else { 0.0 })) + } + }; + let size = |k: &str| -> Result)>> { + match p.get(k) { + None => Ok(None), + Some(Value::String(s)) if s == "auto" => Ok(Some((FlexSize::Auto, None))), + Some(Value::String(s)) if s == "fixed" => Ok(Some((FlexSize::Fixed, None))), + Some(Value::Number(n)) => Ok(n.as_f64().filter(|v| v.is_finite() && *v > 0.0).map(|v| (FlexSize::Fixed, Some(v.min(100_000.0))))), + Some(v) => Err(bad("flex.set", format!("`{k}`: auto, fixed or a size, not {v}"))), + } + }; + let (width, height) = (size("width")?, size("height")?); + let (gap_h, gap_v, wrap) = (num("gapH"), num("gapV"), p.get("wrap").and_then(Value::as_bool)); + s.edit(|d, _| { + let mut changed = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + let Some(f) = it.flex.as_mut() else { continue }; + if let Some(v) = direction { + f.direction = v; + } + if let Some(v) = wrap { + f.wrap = v; + } + if let Some(v) = justify { + f.justify = v; + } + if let Some(v) = align { + f.align = v; + } + if let Some(c) = cell { + // The grid is what you see: its column is left/centre/right, its row top/middle/bottom. + let pos = |i: u64| [FlexAlign::Start, FlexAlign::Center, FlexAlign::End][i as usize % 3]; + let (h, v) = (pos(c % 3), pos(c / 3)); + let (main, cross) = if f.direction.is_row() { (h, v) } else { (v, h) }; + let main = if f.direction.is_reverse() { + match main { + FlexAlign::Start => FlexAlign::End, + FlexAlign::End => FlexAlign::Start, + m => m, + } + } else { + main + }; + if !f.justify.distributes() { + f.justify = match main { + FlexAlign::Start => FlexJustify::FlexStart, + FlexAlign::Center => FlexJustify::Center, + FlexAlign::End => FlexJustify::FlexEnd, + }; + } + f.align = cross; + } + if let Some(v) = padding { + f.padding = v; + } + if let Some(v) = gap_h { + f.gap_h = v; + } + if let Some(v) = gap_v { + f.gap_v = v; + } + let r = it.inner_bounds(); + let mut rect = r; + if let Some((mode, value)) = width { + if let Some(f) = it.flex.as_mut() { + f.width = mode; + } + if let Some(w) = value { + rect.x1 = rect.x0 + w; + } + } + if let Some((mode, value)) = height { + if let Some(f) = it.flex.as_mut() { + f.height = mode; + } + if let Some(h) = value { + rect.y1 = rect.y0 + h; + } + } + if rect != r { + it.path = shapes::rectangle(rect); + } + changed += 1; + } + Ok(json!({"changed": changed})) + }) +} + +fn reorder(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("flex.reorder", "missing `id`"))?; + let to = p.get("to").and_then(Value::as_i64); + let by = p.get("by").and_then(Value::as_i64); + if to.is_none() && by.is_none() { + return Err(bad("flex.reorder", "give `to` or `by`")); + } + s.edit(|d, sel| { + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let (last, parent_path) = loc.path.split_last().ok_or(designcraft_doc::DocError::NoItem(id))?; + let parent_loc = designcraft_doc::ItemLoc { spread: loc.spread, path: parent_path.to_vec() }; + let parent = d + .item_at(&parent_loc) + .filter(|c| c.flex.is_some()) + .map(|c| c.id) + .ok_or_else(|| bad("flex.reorder", "the object isn't in a Flex Layout"))?; + let c = d.item_mut(parent).ok_or(designcraft_doc::DocError::NoItem(parent))?; + let Content::Group { items } = &mut c.content else { return Err(bad("flex.reorder", "the object isn't in a Flex Layout")) }; + let n = items.len() as i64; + let from = *last as i64; + let target = to.unwrap_or(from + by.unwrap_or(0)).clamp(0, n - 1); + let moved = items.remove(from as usize); + items.insert(target as usize, moved); + *sel = Selection::items(vec![id]); + Ok(json!({"index": target})) + }) +} + +/// An object moved inside a container goes to the place it was dropped on: before the nearest +/// object if it lies before that one's centre along the main axis, else after it. +pub(crate) fn drop_in_container(d: &mut Document, id: ItemId) { + let Some(loc) = d.find(id) else { return }; + let Some((&at, parent)) = loc.path.split_last() else { return }; + let parent_loc = designcraft_doc::ItemLoc { spread: loc.spread, path: parent.to_vec() }; + let Some(cid) = d.item_at(&parent_loc).filter(|c| c.flex.is_some()).map(|c| c.id) else { return }; + let Some(c) = d.item_mut(cid) else { return }; + let Some(f) = c.flex.clone() else { return }; + let Content::Group { items } = &mut c.content else { return }; + if at >= items.len() { + return; + } + let moved = items.remove(at); + let m = moved.bounds().center(); + let along = |p: designcraft_geom::Point| if f.direction.is_row() { p.x } else { p.y }; + let nearest = items.iter().enumerate().map(|(i, k)| (i, k.bounds().center())).min_by(|a, b| (a.1 - m).hypot().total_cmp(&(b.1 - m).hypot())); + let index = match nearest { + Some((i, k)) => { + let before = (along(m) < along(k)) != f.direction.is_reverse(); + if before { i } else { i + 1 } + } + None => 0, + }; + items.insert(index.min(items.len()), moved); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session_with_boxes(boxes: &[[f64; 4]]) -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 1920, "height": 1080, "margins": 84})).unwrap(); + let ids = boxes.iter().map(|r| s.execute("frame.create", &json!({"rect": r})).unwrap()["id"].as_u64().unwrap()).collect(); + (s, ids) + } + + fn rect_of(s: &Session, id: u64) -> Rect { + s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds() + } + + /// The video: four objects in a row become a container that keeps them where they are (Row, + /// Auto size, Space Between at their spacing, aligned to the top), then a fixed 2-column layout. + #[test] + fn four_objects_in_a_row_then_two_wrapped_columns() { + // As in the video after Shift+Cmd shrinks them: 400 × 430, 22 apart. + let boxes = [[84.0, 461.0, 484.0, 891.0], [506.0, 461.0, 906.0, 891.0], [928.0, 461.0, 1328.0, 891.0], [1350.0, 461.0, 1750.0, 891.0]]; + let (mut s, ids) = session_with_boxes(&boxes); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap(); + let d = s.doc().unwrap().doc.clone(); + let it = d.item(ItemId(c)).unwrap(); + let f = it.flex.clone().unwrap(); + assert_eq!( + (f.direction, f.justify, f.align, f.width, f.height, f.wrap), + (FlexDirection::Row, FlexJustify::SpaceBetween, FlexAlign::Start, FlexSize::Auto, FlexSize::Auto, false) + ); + assert!((f.gap_h - 22.0).abs() < 1e-9); + assert_eq!(it.bounds(), Rect::new(84.0, 461.0, 1750.0, 891.0)); + for (id, b) in ids.iter().zip(&boxes) { + assert_eq!(rect_of(&s, *id), Rect::new(b[0], b[1], b[2], b[3]), "nothing moves"); + } + assert_eq!(d.spreads[0].items.len(), 1, "the objects are in the container"); + // Resize the container (Fixed), Flex Start, a wrapping column, spacing, centred. + s.execute( + "flex.set", + &json!({"width": 868, "height": 912, "justify": "flexStart", "direction": "column", "wrap": true, "gapH": 56, "gapV": 38}), + ) + .unwrap(); + s.execute("flex.set", &json!({"cell": 4})).unwrap(); + let f = s.doc().unwrap().doc.item(ItemId(c)).unwrap().flex.clone().unwrap(); + assert_eq!((f.justify, f.align, f.width), (FlexJustify::Center, FlexAlign::Center, FlexSize::Fixed)); + // Two columns of two: 400 + 56 + 400 across 868, 430 + 38 + 430 down 912, centred. + let r: Vec = ids.iter().map(|i| rect_of(&s, *i)).collect(); + assert_eq!(r[0].x0, r[1].x0); + assert_eq!(r[2].x0, r[3].x0); + assert!((r[2].x0 - r[0].x1 - 56.0).abs() < 1e-6); + assert!((r[1].y0 - r[0].y1 - 38.0).abs() < 1e-6); + let c_rect = s.doc().unwrap().doc.item(ItemId(c)).unwrap().bounds(); + assert!((r[0].x0 - c_rect.x0 - 6.0).abs() < 1e-6 && (r[0].y0 - c_rect.y0 - 7.0).abs() < 1e-6, "{r:?} in {c_rect:?}"); + // Reorder: 2 goes to the top of the second column. + s.execute("flex.reorder", &json!({"id": ids[1], "to": 2})).unwrap(); + assert_eq!(rect_of(&s, ids[1]).x0, r[2].x0, "now in the second column"); + assert_eq!(rect_of(&s, ids[1]).y0, r[0].y0); + // One undo step per command; undo the reorder. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(rect_of(&s, ids[1]), r[1]); + } + + /// Resizing the container by its handles changes its box (Fixed), not its objects' size. + #[test] + fn resizing_a_container_reflows_instead_of_scaling() { + let (mut s, ids) = session_with_boxes(&[[100.0, 100.0, 200.0, 200.0], [220.0, 100.0, 320.0, 200.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("flex.set", &json!({"justify": "flexEnd"})).unwrap(); + s.execute("transform.resize", &json!({"from": [100, 100, 320, 200], "to": [100, 100, 520, 300]})).unwrap(); + let it = s.doc().unwrap().doc.item(ItemId(c)).unwrap().clone(); + let b = it.bounds(); + assert!((b.x1 - 520.0).abs() < 1e-6 && (b.y1 - 300.0).abs() < 1e-6 && b.x0 == 100.0, "{b:?}"); + let f = it.flex.unwrap(); + assert_eq!((f.width, f.height), (FlexSize::Fixed, FlexSize::Fixed)); + let r1 = rect_of(&s, ids[1]); + assert!((r1.x1 - 520.0).abs() < 1e-6 && (r1.width() - 100.0).abs() < 1e-6 && r1.y0 == 100.0, "same size, at the end of the row: {r1:?}"); + assert_eq!(rect_of(&s, ids[0]).width(), 100.0); + s.execute("transform.set", &json!({"width": 300, "ref": 0})).unwrap(); + assert!((rect_of(&s, ids[1]).x1 - 400.0).abs() < 1e-6); + } + + /// Moving an object inside the container is undone by the layout (it places them); a new + /// object in the container is laid out at once. + #[test] + fn the_layout_follows_every_edit() { + let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [150.0, 0.0, 250.0, 50.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("flex.add", &json!({})).unwrap(); + s.execute("transform.set", &json!({"ids": [ids[0]], "width": 300, "ref": 0})).unwrap(); + // Auto width: the container grows, the second object follows at the spacing. + assert_eq!(rect_of(&s, ids[1]).x0, 350.0); + } + + #[test] + fn remove_groups_the_objects_where_they_are() { + let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [150.0, 0.0, 250.0, 50.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("flex.add", &json!({})).unwrap(); + s.execute("flex.set", &json!({"justify": "flexStart", "gapH": 10})).unwrap(); + let before = rect_of(&s, ids[1]); + let r = s.execute("flex.remove", &json!({})).unwrap(); + let g = r["ids"][0].as_u64().unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ItemId(g)).unwrap().is_group()); + assert_eq!(rect_of(&s, ids[1]), before); + assert_eq!(before.x0, 110.0); + // A group converts back. + s.execute("flex.convert", &json!({"ids": [g]})).unwrap(); + assert!(s.doc().unwrap().doc.item(ItemId(g)).unwrap().flex.is_some()); + } + + /// Dragging an object in a container drops it in the nearest place (the video moves group 2 to + /// the top of the second column). + #[test] + fn dragging_an_object_reorders_the_container() { + let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [110.0, 0.0, 210.0, 50.0], [220.0, 0.0, 320.0, 50.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("flex.add", &json!({})).unwrap(); + s.execute("flex.set", &json!({"justify": "flexStart"})).unwrap(); + // The first box dropped past the third's centre: it goes last. + s.execute("transform.move", &json!({"ids": [ids[0]], "dx": 250, "dy": 5})).unwrap(); + let x: Vec = ids.iter().map(|i| rect_of(&s, *i).x0.round()).collect(); + assert_eq!(x, [220.0, 0.0, 110.0]); + // Back before the second (now at 0): first again. + s.execute("transform.move", &json!({"ids": [ids[0]], "dx": -225, "dy": 0})).unwrap(); + let x: Vec = ids.iter().map(|i| rect_of(&s, *i).x0.round()).collect(); + assert_eq!(x, [0.0, 110.0, 220.0]); + } + + /// Flex settings are saved with the document. + #[test] + fn a_container_round_trips_through_the_file() { + let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0], [110.0, 0.0, 210.0, 50.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + let c = s.execute("flex.add", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("flex.set", &json!({"direction": "columnReverse", "wrap": true, "padding": [1, 2, 3, 4], "gapV": 7})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let back = crate::cmd::from_bytes(&crate::cmd::to_bytes(&d)).unwrap(); + assert_eq!(back.item(ItemId(c)).unwrap().flex, d.item(ItemId(c)).unwrap().flex); + assert!(back.item(ItemId(c)).unwrap().flex.is_some()); + } + + #[test] + fn bad_params_are_refused() { + let (mut s, ids) = session_with_boxes(&[[0.0, 0.0, 100.0, 50.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("flex.add", &json!({})).unwrap(); + assert!(s.execute("flex.set", &json!({"direction": "diagonal"})).is_err()); + assert!(s.execute("flex.set", &json!({"cell": 9})).is_err()); + assert!(s.execute("flex.set", &json!({"padding": [1, 2]})).is_err()); + assert!(s.execute("flex.reorder", &json!({"id": ids[0]})).is_err()); + s.execute("flex.set", &json!({"gapH": f64::MAX, "width": -5})).unwrap(); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b3bbb180b..0bfb9727c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -17,6 +17,7 @@ mod export; mod file; pub(crate) mod find; pub use find::same_find_domain; +pub(crate) mod flex; mod fonts; mod guides; mod index; @@ -162,6 +163,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(anchors::specs()); v.extend(articles::specs()); v.extend(paths::specs()); + v.extend(flex::specs()); v.extend(book::specs()); v.extend(buttons::specs()); v.extend(captions::specs()); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82c58c50b..fb9c084f7 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1078,6 +1078,12 @@ fn transform_move(s: &mut Session, p: &Value) -> Result { } it.xf = Affine::translate((dx, dy)) * it.xf; } + // An object dragged inside a Flex Layout takes the place it was dropped on. + if !copy { + for id in &ids { + super::flex::drop_in_container(d, *id); + } + } Ok(json!({"moved": ids.len(), "ids": ids.iter().map(|i| i.0).collect::>()})) }) } @@ -1108,6 +1114,8 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result { it.xf = Affine::translate(m * c - c) * it.xf; } else if scale_content { scale_item(it, m, (sx * sy).abs().sqrt(), strokes); + } else if it.flex.is_some() { + resize_container(it, m); } else if matches!(it.content, Content::Group { .. }) { it.xf = m * it.xf; } else { @@ -1127,6 +1135,23 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result { }) } +/// A Flex Layout container takes the new box (its objects keep their size; the layout places them), +/// and a side whose length changes becomes Fixed, as dragging a container's handle does in InDesign. +fn resize_container(it: &mut Item, m: Affine) { + let before = it.inner_bounds(); + let inner = it.xf.inverse() * m * it.xf; + bake(it, inner); + let after = it.inner_bounds(); + if let Some(f) = it.flex.as_mut() { + if (after.width() - before.width()).abs() > 1e-6 { + f.width = designcraft_doc::FlexSize::Fixed; + } + if (after.height() - before.height()).abs() > 1e-6 { + f.height = designcraft_doc::FlexSize::Fixed; + } + } +} + fn union_bounds(d: &Document, ids: &[ItemId]) -> Rect { ids.iter().filter_map(|i| d.item(*i)).map(Item::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO) } @@ -1374,7 +1399,9 @@ fn transform_set(s: &mut Session, p: &Value) -> Result { * Affine::translate((-geom.x0, -geom.y0)); for id in &ids { if let Some(it) = d.item_mut(*id) { - if matches!(it.content, Content::Group { .. }) { + if it.flex.is_some() { + resize_container(it, m); + } else if matches!(it.content, Content::Group { .. }) { it.xf = m * it.xf; } else { let inner = it.xf.inverse() * m * it.xf; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff8fb87bb..2b6f46638 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -560,6 +560,7 @@ impl Session { let mut sel = st.selection.clone(); let r = f(&mut doc, &mut sel)?; crate::cmd::sync_placeholders(&mut doc); + crate::cmd::flex::sync(&mut doc); doc.sync_endnote_story(); st.doc = Arc::new(doc); st.selection = sel; diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0d581e992..755165a10 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -26,6 +26,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("table", "Table", "panel-table"), ("align", "Align", "panel-align"), ("pathfinder", "Pathfinder", "tool-scissors"), + ("flexLayout", "Flex Layout", "panel-flex"), ("links", "Links", "panel-links"), ("info", "Info", "panel-info"), ("preflight", "Preflight", "panel-preflight"), @@ -210,6 +211,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "textWrap" => panels::properties::wrap_panel(app, ui), "align" => panels::properties::align_panel(app, ui), "pathfinder" => panels::properties::pathfinder_panel(app, ui), + "flexLayout" => panels::properties::flex_panel(app, ui), "color" => panels::swatches::color_panel(app, ui), "gradient" => panels::swatches::gradient_panel(app, ui), "effects" => panels::properties::effects_panel(app, ui), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 04ee153c0..cc171865c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,75 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Flex Layout", ["Flex-Layout", "Mise en page flexible", "Diseño flexible", "フレックスレイアウト", "弹性布局"]), + ( + "Create Flex Layout", + ["Flex-Layout erstellen", "Créer une mise en page flexible", "Crear diseño flexible", "フレックスレイアウトを作成", "创建弹性布局"], + ), + ( + "Add to Flex Layout", + [ + "Zu Flex-Layout hinzufügen", + "Ajouter à la mise en page flexible", + "Añadir a diseño flexible", + "フレックスレイアウトに追加", + "添加到弹性布局", + ], + ), + ( + "Convert to Flex Layout", + [ + "In Flex-Layout umwandeln", + "Convertir en mise en page flexible", + "Convertir en diseño flexible", + "フレックスレイアウトに変換", + "转换为弹性布局", + ], + ), + ( + "Remove Flex Layout", + ["Flex-Layout entfernen", "Supprimer la mise en page flexible", "Quitar diseño flexible", "フレックスレイアウトを削除", "移除弹性布局"], + ), + ( + "Show Flex Controls", + [ + "Flex-Steuerelemente einblenden", + "Afficher les commandes flexibles", + "Mostrar controles flexibles", + "フレックスコントロールを表示", + "显示弹性控件", + ], + ), + ( + "Flex Layout Options", + ["Flex-Layout-Optionen", "Options de mise en page flexible", "Opciones de diseño flexible", "フレックスレイアウトオプション", "弹性布局选项"], + ), + ( + "Reorder in Flex Layout", + [ + "Im Flex-Layout umordnen", + "Réorganiser dans la mise en page flexible", + "Reordenar en diseño flexible", + "フレックスレイアウト内で並べ替え", + "在弹性布局中重新排序", + ], + ), + ("Flex Start", ["Flex-Anfang", "Début flexible", "Inicio flexible", "フレックス開始", "弹性起点"]), + ("Flex End", ["Flex-Ende", "Fin flexible", "Fin flexible", "フレックス終了", "弹性终点"]), + ("Space Between", ["Abstand dazwischen", "Espace entre", "Espacio entre", "間隔を均等", "两端对齐"]), + ("Space Around", ["Abstand ringsum", "Espace autour", "Espacio alrededor", "周囲に間隔", "周围间距"]), + ("Space Evenly", ["Gleichmäßig verteilen", "Espace uniforme", "Espacio uniforme", "均等に配置", "均匀分布"]), + ("Row", ["Zeile", "Ligne", "Fila", "行", "行"]), + ("Reverse Row", ["Zeile umgekehrt", "Ligne inversée", "Fila invertida", "行(逆方向)", "反向行"]), + ("Reverse Column", ["Spalte umgekehrt", "Colonne inversée", "Columna invertida", "列(逆方向)", "反向列"]), + ("Wrap Content", ["Inhalt umbrechen", "Renvoyer le contenu à la ligne", "Ajustar contenido", "コンテンツを折り返す", "内容换行"]), + ("Spacing", ["Abstände", "Espacement", "Espaciado", "間隔", "间距"]), + ("Horizontal Spacing", ["Horizontaler Abstand", "Espacement horizontal", "Espaciado horizontal", "水平方向の間隔", "水平间距"]), + ("Vertical Spacing", ["Vertikaler Abstand", "Espacement vertical", "Espaciado vertical", "垂直方向の間隔", "垂直间距"]), + ("Constrain proportions", ["Proportionen beibehalten", "Conserver les proportions", "Restringir proporciones", "縦横比を固定", "约束比例"]), + ("Auto", ["Automatisch", "Automatique", "Automático", "自動", "自动"]), + ("W:", ["B:", "L :", "An.:", "幅:", "宽:"]), + ("H:", ["H:", "H :", "Al.:", "高さ:", "高:"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index f68e7587e..a1c5824db 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Create Flex Layout", "إنشاء تخطيط مرن"), + ("Add to Flex Layout", "إضافة إلى التخطيط المرن"), + ("Convert to Flex Layout", "تحويل إلى تخطيط مرن"), + ("Remove Flex Layout", "إزالة التخطيط المرن"), + ("Show Flex Controls", "إظهار عناصر التحكم المرنة"), + ("Flex Layout Options", "خيارات التخطيط المرن"), + ("Reorder in Flex Layout", "إعادة الترتيب في التخطيط المرن"), + ("Flex Start", "بداية مرنة"), + ("Flex End", "نهاية مرنة"), + ("Space Between", "مسافة بينية"), + ("Space Around", "مسافة حول"), + ("Space Evenly", "توزيع متساوٍ"), + ("Reverse Row", "صف معكوس"), + ("Reverse Column", "عمود معكوس"), + ("Wrap Content", "التفاف المحتوى"), + ("Spacing", "التباعد"), + ("Horizontal Spacing", "التباعد الأفقي"), + ("Vertical Spacing", "التباعد الرأسي"), + ("Constrain proportions", "تقييد النسب"), + ("Auto", "تلقائي"), + ("W:", "ع:"), + ("H:", "ا:"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index e91307f18..bf2783769 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Create Flex Layout", "Crea Layout Flex"), + ("Add to Flex Layout", "Aggiungi a Layout Flex"), + ("Convert to Flex Layout", "Converti in Layout Flex"), + ("Remove Flex Layout", "Rimuovi Layout Flex"), + ("Show Flex Controls", "Mostra controlli Flex"), + ("Flex Layout Options", "Opzioni Layout Flex"), + ("Reorder in Flex Layout", "Riordina nel Layout Flex"), + ("Flex Start", "Inizio Flex"), + ("Flex End", "Estremità Flex"), + ("Space Between", "Spazio tra"), + ("Space Around", "Spazio intorno"), + ("Space Evenly", "Disponi lo spazio in modo uniforme"), + ("Reverse Row", "Riga inversa"), + ("Reverse Column", "Colonna inversa"), + ("Wrap Content", "Disponi testo"), + ("Spacing", "Spaziatura"), + ("Horizontal Spacing", "Spaziatura orizzontale"), + ("Vertical Spacing", "Spaziatura verticale"), + ("Constrain proportions", "Vincola proporzioni"), + ("Auto", "Automatico"), + ("W:", "L:"), + ("H:", "A:"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), @@ -1502,7 +1524,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Slug", "Indicazioni"), ("Binding", "Rilegatura"), ("Rotation", "Rotazione"), - ("Flex Layout", "Layout flessibile"), + ("Flex Layout", "Layout Flex"), ("Auto Size", "Dimensione automatica"), ("Kerning", "Crenatura"), ("Alignment", "Allineamento"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index f45ee094b..d422b45a7 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Create Flex Layout", "フレックスレイアウトを作成"), + ("Add to Flex Layout", "フレックスレイアウトに追加"), + ("Convert to Flex Layout", "フレックスレイアウトに変換"), + ("Remove Flex Layout", "フレックスレイアウトを削除"), + ("Show Flex Controls", "フレックスコントロールを表示"), + ("Flex Layout Options", "フレックスレイアウトオプション"), + ("Reorder in Flex Layout", "フレックスレイアウト内で並べ替え"), + ("Flex Start", "フレックス開始"), + ("Flex End", "フレックス終了"), + ("Space Between", "間隔を均等"), + ("Space Around", "周囲に間隔"), + ("Space Evenly", "均等に配置"), + ("Reverse Row", "行(逆方向)"), + ("Reverse Column", "列(逆方向)"), + ("Wrap Content", "コンテンツを折り返す"), + ("Spacing", "間隔"), + ("Horizontal Spacing", "水平方向の間隔"), + ("Vertical Spacing", "垂直方向の間隔"), + ("Constrain proportions", "縦横比を固定"), + ("Auto", "自動"), + ("W:", "幅:"), + ("H:", "高さ:"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 09e639e5c..625863e4d 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Create Flex Layout", "Criar layout flexível"), + ("Add to Flex Layout", "Adicionar ao layout flexível"), + ("Convert to Flex Layout", "Converter em layout flexível"), + ("Remove Flex Layout", "Remover layout flexível"), + ("Show Flex Controls", "Mostrar controles flexíveis"), + ("Flex Layout Options", "Opções de layout flexível"), + ("Reorder in Flex Layout", "Reordenar no layout flexível"), + ("Flex Start", "Início flexível"), + ("Flex End", "Fim flexível"), + ("Space Between", "Espaço entre"), + ("Space Around", "Espaço ao redor"), + ("Space Evenly", "Espaço uniforme"), + ("Reverse Row", "Linha invertida"), + ("Reverse Column", "Coluna invertida"), + ("Wrap Content", "Quebrar conteúdo"), + ("Spacing", "Espaçamento"), + ("Horizontal Spacing", "Espaçamento horizontal"), + ("Vertical Spacing", "Espaçamento vertical"), + ("Constrain proportions", "Restringir proporções"), + ("Auto", "Automático"), + ("W:", "L:"), + ("H:", "A:"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index eab4159d0..87cbe15c0 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,28 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Create Flex Layout", "Створити гнучкий макет"), + ("Add to Flex Layout", "Додати до гнучкого макета"), + ("Convert to Flex Layout", "Перетворити на гнучкий макет"), + ("Remove Flex Layout", "Прибрати гнучкий макет"), + ("Show Flex Controls", "Показати елементи гнучкого макета"), + ("Flex Layout Options", "Параметри гнучкого макета"), + ("Reorder in Flex Layout", "Змінити порядок у гнучкому макеті"), + ("Flex Start", "Початок"), + ("Flex End", "Кінець"), + ("Space Between", "Простір між"), + ("Space Around", "Простір навколо"), + ("Space Evenly", "Рівномірно"), + ("Reverse Row", "Рядок у зворотному порядку"), + ("Reverse Column", "Стовпець у зворотному порядку"), + ("Wrap Content", "Переносити вміст"), + ("Spacing", "Відступи"), + ("Horizontal Spacing", "Горизонтальний відступ"), + ("Vertical Spacing", "Вертикальний відступ"), + ("Constrain proportions", "Зберігати пропорції"), + ("Auto", "Авто"), + ("W:", "Ш:"), + ("H:", "В:"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c93464374..a0c665c61 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -550,6 +550,56 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { } } } + // Flex Layout panel (original drawings): direction arrows, wrap, justification, spacing. + "flex-right" | "flex-left" | "flex-down" | "flex-up" => { + let pts: [(f32, f32); 5] = match name { + "flex-right" => [(3.0, 10.0), (16.0, 10.0), (11.5, 6.0), (16.0, 10.0), (11.5, 14.0)], + "flex-left" => [(17.0, 10.0), (4.0, 10.0), (8.5, 6.0), (4.0, 10.0), (8.5, 14.0)], + "flex-down" => [(10.0, 3.0), (10.0, 16.0), (6.0, 11.5), (10.0, 16.0), (14.0, 11.5)], + _ => [(10.0, 17.0), (10.0, 4.0), (6.0, 8.5), (10.0, 4.0), (14.0, 8.5)], + }; + pen.line(&pts[..2]); + pen.line(&pts[2..]); + } + "flex-wrap" => { + pen.line(&[(16.0, 4.0), (16.0, 12.0), (5.0, 12.0)]); + pen.line(&[(8.5, 8.5), (5.0, 12.0), (8.5, 15.5)]); + } + "flex-start" | "flex-center" | "flex-end" | "flex-between" | "flex-around" | "flex-evenly" => { + // Three bars in a box, where the justification puts them. + pen.rect(1.5, 6.0, 18.5, 14.0); + let xs: [f32; 3] = match name { + "flex-start" => [3.0, 6.0, 9.0], + "flex-center" => [6.5, 9.5, 12.5], + "flex-end" => [10.0, 13.0, 16.0], + "flex-between" => [3.0, 9.5, 16.0], + "flex-around" => [4.5, 9.5, 14.5], + _ => [5.0, 9.5, 14.0], + }; + for x in xs { + pen.frect(x, 7.5, x + 1.8, 12.5); + } + } + "gap-h" => { + pen.line(&[(3.0, 4.0), (3.0, 16.0)]); + pen.line(&[(17.0, 4.0), (17.0, 16.0)]); + pen.line(&[(5.5, 10.0), (14.5, 10.0)]); + pen.line(&[(8.0, 7.5), (5.5, 10.0), (8.0, 12.5)]); + pen.line(&[(12.0, 7.5), (14.5, 10.0), (12.0, 12.5)]); + } + "gap-v" => { + pen.line(&[(4.0, 3.0), (16.0, 3.0)]); + pen.line(&[(4.0, 17.0), (16.0, 17.0)]); + pen.line(&[(10.0, 5.5), (10.0, 14.5)]); + pen.line(&[(7.5, 8.0), (10.0, 5.5), (12.5, 8.0)]); + pen.line(&[(7.5, 12.0), (10.0, 14.5), (12.5, 12.0)]); + } + "panel-flex" => { + pen.rect(2.5, 3.5, 17.5, 16.5); + pen.frect(5.0, 6.0, 8.5, 14.0); + pen.frect(11.5, 6.0, 15.0, 14.0); + pen.line(&[(8.5, 10.0), (11.5, 10.0)]); + } // ---- Properties panel glyphs (original drawings) "orient-portrait" => { pen.rect(5.5, 3.0, 14.5, 17.0); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9a581d4cf..8c7fe1e82 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -482,6 +482,13 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:transform.again|Transform Sequence Again|{\"sequence\": true}", "cmd:transform.again|Transform Sequence Again Individually|{\"sequence\": true, \"individually\": true}", "<", + ">Flex Layout", + "cmd:flex.add", + "cmd:flex.convert", + "cmd:flex.remove", + "-", + "ui:window.panel|Show Flex Controls|{\"panel\": \"flexLayout\"}", + "<", ">Paths", "cmd:object.makeCompoundPath", "cmd:object.releaseCompoundPath", diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 358ec76d3..52eecfc32 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -121,6 +121,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { section(ui, crate::i18n::tr(&app.ui.language, "Align"), true); align_row(app, ui); divider(ui); + section(ui, crate::i18n::tr(&app.ui.language, "Flex Layout"), true); + flex_section(app, ui); + divider(ui); if i.count > 1 { section(ui, crate::i18n::tr(&app.ui.language, "Pathfinder"), true); pathfinder_row(app, ui); @@ -2520,3 +2523,282 @@ mod effects_panel_tests { assert_eq!(st.history.undo.len(), undo, "disabled controls do not create undo steps"); } } + +// ---------------------------------------------------------------- Flex Layout + +/// (justification, icon, label) in the Justification menu's order. +const FLEX_JUSTIFY: [(&str, &str, &str); 6] = [ + ("flexStart", "flex-start", "Flex Start"), + ("center", "flex-center", "Center"), + ("flexEnd", "flex-end", "Flex End"), + ("spaceBetween", "flex-between", "Space Between"), + ("spaceAround", "flex-around", "Space Around"), + ("spaceEvenly", "flex-evenly", "Space Evenly"), +]; + +/// The selected Flex Layout container (the first), with its objects' bounds. +fn selected_container(app: &DesignApp) -> Option<(designcraft_doc::Item, Vec)> { + let st = app.session.active()?; + let it = st.selection.items.iter().filter_map(|i| st.doc.item(*i)).find(|it| it.flex.is_some())?.clone(); + let kids = match &it.content { + designcraft_doc::Content::Group { items } => items.iter().map(|c| c.bounds()).collect(), + _ => Vec::new(), + }; + Some((it, kids)) +} + +/// The alignment grid: 3 × 3 cells (one column or row of three when a space justification spreads +/// the objects along the main axis); the chosen cell shows the direction's arrow. +fn flex_grid(ui: &mut Ui, r: Rect, f: Option<&designcraft_doc::FlexLayout>) -> Option { + use designcraft_doc::{FlexAlign, FlexJustify}; + let t = Tokens::get(ui.ctx()); + let resp = ui.interact(r, ui.id().with("flex_grid"), Sense::click()); + ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + let cell = r.width() / 3.0; + let pos = |a: FlexAlign| match a { + FlexAlign::Start => 0, + FlexAlign::Center => 1, + FlexAlign::End => 2, + }; + // The chosen (column, row), and whether a space justification leaves only one axis. + let (chosen, spread_row) = match f { + Some(f) => { + let main = match f.justify { + FlexJustify::Center => 1, + FlexJustify::FlexEnd => 2, + _ => 0, + }; + let main = if f.direction.is_reverse() { 2 - main } else { main }; + let cross = pos(f.align); + (if f.direction.is_row() { (main, cross) } else { (cross, main) }, f.justify.distributes().then_some(f.direction.is_row())) + } + None => ((0, 0), None), + }; + let mut picked = None; + for row in 0..3usize { + for col in 0..3usize { + match spread_row { + Some(true) if col != 0 => continue, + Some(false) if row != 0 => continue, + _ => {} + } + let c = Rect::from_min_size(r.min + vec2(col as f32 * cell, row as f32 * cell), vec2(cell, cell)); + let on = match spread_row { + Some(true) => row == chosen.1, + Some(false) => col == chosen.0, + None => (col, row) == chosen, + }; + if on && f.is_some() { + ui.painter().rect_filled(c.shrink(2.0), 2.0, t.well); + let icon = match f.map(|f| f.direction) { + Some(designcraft_doc::FlexDirection::RowReverse) => "flex-left", + Some(designcraft_doc::FlexDirection::Column) => "flex-down", + Some(designcraft_doc::FlexDirection::ColumnReverse) => "flex-up", + _ => "flex-right", + }; + icons::paint(ui.painter(), c.shrink(3.0), icon, t.text_strong); + } else { + ui.painter().circle_filled(c.center(), 1.5, t.icon); + } + if resp.clicked() && resp.interact_pointer_pos().is_some_and(|p| c.contains(p)) { + picked = Some((row * 3 + col) as u8); + } + } + } + picked +} + +/// Flex Layout controls, as InDesign's panel: Create Flex Layout; alignment grid, width and height +/// (Auto/Fixed); justification, direction and wrap; padding and spacing. +fn flex_section(app: &mut DesignApp, ui: &mut Ui) { + use designcraft_doc::{FlexSize, flex}; + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let u = units(app); + let has_sel = app.session.active().is_some_and(|s| !s.selection.items.is_empty()); + let container = selected_container(app); + let f = container.as_ref().and_then(|(it, _)| it.flex.clone()); + let mut on = f.is_some(); + let resp = ui.add_enabled_ui(has_sel, |ui| widgets::checkbox(ui, &mut on, &tr("Create Flex Layout"))).inner; + if resp.changed() { + let _ = app.run(if on { "flex.add" } else { "flex.remove" }, json!({})); + return; + } + ui.add_space(4.0); + let enabled = f.is_some(); + let rect = container.as_ref().map(|(it, _)| it.inner_bounds()); + let arranged = container.as_ref().and_then(|(it, kids)| it.flex.as_ref().map(|f| flex::arrange(f, it.inner_bounds(), kids))); + let mut set: Option = None; + // Alignment grid, W and H with Auto/Fixed, constrain proportions. + let b = block(ui, 2.0 * FIELD_H + 5.0); + ui.add_enabled_ui(enabled, |ui| { + if let Some(c) = flex_grid(ui, sub(b, 0.0, 0.0, 48.0, 48.0), f.as_ref()) { + set = Some(json!({"cell": c})); + } + let linked = temp_bool(ui, "flex_size_linked", false); + for (k, (label, key, mode, len)) in [ + ("W:", "width", f.as_ref().map(|f| f.width), rect.map(|r| r.width())), + ("H:", "height", f.as_ref().map(|f| f.height), rect.map(|r| r.height())), + ] + .into_iter() + .enumerate() + { + let y = k as f32 * (FIELD_H + 5.0); + ui.painter().text( + sub(b, 56.0, y, 18.0, FIELD_H).right_center(), + egui::Align2::RIGHT_CENTER, + tr(label), + egui::FontId::proportional(11.5), + t.text, + ); + let nv = place(ui, sub(b, 78.0, y, 66.0, FIELD_H), |ui| NumField::measure(key, len, u).width(66.0).range(0.0, 100_000.0).show(ui)); + if let Some(v) = nv { + let mut p = json!({ key: v }); + if linked + && let (Some(r), Some(len)) = (rect, len) + && len > 1e-9 + { + let other = if key == "width" { ("height", r.height()) } else { ("width", r.width()) }; + p[other.0] = json!(other.1 * v / len); + } + set = Some(p); + } + let names = [tr("Fixed"), tr("Auto")]; + let cur = if mode == Some(FlexSize::Auto) { 1 } else { 0 }; + if let Some(i) = place(ui, sub(b, 148.0, y, 62.0, FIELD_H), |ui| widgets::dropdown_list(ui, &names[cur], 62.0, &names, Some(cur))) { + set = Some(json!({ key: if i == 1 { "auto" } else { "fixed" } })); + } + } + let clicked = place(ui, sub(b, 214.0, 12.5, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Constrain proportions"), vec2(22.0, 21.0)) + .clicked() + }); + if clicked { + set_temp_bool(ui, "flex_size_linked", !linked); + } + }); + ui.add_space(6.0); + // Justification, direction, wrap. + let b = block(ui, FIELD_H); + ui.add_enabled_ui(enabled, |ui| { + let cur = f.as_ref().map(|f| f.justify).unwrap_or_default(); + let cur = serde_json::to_value(cur).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + let icon = FLEX_JUSTIFY.iter().find(|j| j.0 == cur).map(|j| j.1).unwrap_or("flex-start"); + let resp = place(ui, sub(b, 0.0, 0.0, 58.0, FIELD_H), |ui| { + preview_dropdown(ui, 58.0, |p, r| icons::paint(p, r.shrink2(vec2(6.0, 2.0)), icon, egui::Color32::from_gray(60))) + }); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(150.0); + for (id, icon, label) in FLEX_JUSTIFY { + let r = ui.horizontal(|ui| { + let (ir, _) = ui.allocate_exact_size(vec2(22.0, 16.0), Sense::hover()); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.selectable_label(id == cur, crate::rtl::widget(ui, tr(label))) + }); + if r.inner.clicked() { + set = Some(json!({"justify": id})); + ui.close(); + } + } + }); + let dir = f.as_ref().map(|f| f.direction).unwrap_or_default(); + place(ui, sub(b, 78.0, 0.0, 132.0, FIELD_H), |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (d, icon, tip) in [ + (designcraft_doc::FlexDirection::Row, "flex-right", "Row"), + (designcraft_doc::FlexDirection::RowReverse, "flex-left", "Reverse Row"), + (designcraft_doc::FlexDirection::Column, "flex-down", "Column"), + (designcraft_doc::FlexDirection::ColumnReverse, "flex-up", "Reverse Column"), + ] { + if widgets::icon_toggle_sized(ui, icon, enabled && dir == d, &tr(tip), vec2(31.0, 21.0)).clicked() { + set = Some(json!({"direction": d})); + } + } + }); + let wrap = f.as_ref().is_some_and(|f| f.wrap); + if place(ui, sub(b, 214.0, 0.0, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, "flex-wrap", wrap, &tr("Wrap Content"), vec2(22.0, 21.0)).clicked() + }) { + set = Some(json!({"wrap": !wrap})); + } + }); + // Spacing: padding (linked by default) and the spacing between objects. + section(ui, &tr("Spacing"), true); + let b = block(ui, 3.0 * FIELD_H + 10.0); + ui.add_enabled_ui(enabled, |ui| { + let pad = f.as_ref().map(|f| f.padding).unwrap_or_default(); + let linked = temp_bool(ui, "flex_padding_linked", true); + for (k, (icon, tip, x, y)) in [ + ("margin-top", "Top", 0.0, 0.0), + ("margin-inside", "Left", 112.0, 0.0), + ("margin-bottom", "Bottom", 0.0, 26.0), + ("margin-outside", "Right", 112.0, 26.0), + ] + .into_iter() + .enumerate() + { + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.id().with(("flex_pad", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tr(tip)); + }); + let value = f.as_ref().map(|_| pad[k]); + let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { + NumField::measure(tip, value, u).width(76.0).spinner().range(0.0, 8000.0).show(ui) + }); + if let Some(v) = nv { + let p = if linked { + [v; 4] + } else { + let mut p = pad; + p[k] = v; + p + }; + set = Some(json!({"padding": p})); + } + } + if place(ui, sub(b, 214.0, 12.5, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Make all settings the same"), vec2(22.0, 21.0)) + .clicked() + }) { + set_temp_bool(ui, "flex_padding_linked", !linked); + } + // Spacing between objects: a space justification works the main axis' out (shown greyed); + // the cross axis' only counts when objects wrap. + let (row, distributes, wrap) = f.as_ref().map(|f| (f.direction.is_row(), f.justify.distributes(), f.wrap)).unwrap_or((true, false, false)); + let main_gap = arranged.as_ref().map(|a| a.main_gap); + for (k, (icon, key, tip, x)) in + [("gap-h", "gapH", "Horizontal Spacing", 0.0), ("gap-v", "gapV", "Vertical Spacing", 112.0)].into_iter().enumerate() + { + let main = (k == 0) == row; + let live = if main { !distributes } else { wrap }; + let value = match &f { + Some(_) if main && distributes => main_gap, + Some(f) => Some(if k == 0 { f.gap_h } else { f.gap_v }), + None => None, + }; + let y = 2.0 * FIELD_H + 10.0; + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, if live { t.icon } else { t.text_dim }); + ui.interact(ir, ui.id().with(("flex_gap", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tr(tip)); + }); + let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { + NumField::measure(key, value, u).width(76.0).spinner().range(0.0, 8000.0).enabled(live).show(ui) + }); + if let Some(v) = nv { + set = Some(json!({ key: v })); + } + } + }); + if let Some(p) = set { + let _ = app.run("flex.set", p); + } +} + +/// Window › Flex Layout. +pub fn flex_panel(app: &mut DesignApp, ui: &mut egui::Ui) { + ui.spacing_mut().item_spacing = vec2(6.0, 5.0); + flex_section(app, ui); +} diff --git a/docs/gaps.md b/docs/gaps.md index 4ba05bc4e..e966ad31d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -160,7 +160,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, - Convert Rows/Columns, Object ▸ Interactive ▸ Convert to (form fields), Flex Layout (new in 2026), + Convert Rows/Columns, Object ▸ Interactive ▸ Convert to (form fields), View ▸ Extras badges and Story Editor options. - **Estimate:** 40–70 h (most are thin wrappers over existing engine features). - **Doc:** [ui-parity.md](ui-parity.md) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b8b71540a..2716c6e1d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -16,12 +16,12 @@ of in-scope menu items (75% against the documented menu tree) (see [target-app-p bound. -**Weighted breadth parity: 99%** over 276 features. +**Weighted breadth parity: 99%** over 277 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | -| P1 | 98 | 98 | 0 | 0 | 100% | +| P1 | 99 | 99 | 0 | 0 | 100% | | P2 | 76 | 72 | 4 | 0 | 97% | | Area | Parity | @@ -95,6 +95,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Documents, pages, spreads | Ruler guides | P0 | D | | Documents, pages, spreads | Guides on layers | P1 | D | | Documents, pages, spreads | Liquid guides | P2 | D | +| Documents, pages, spreads | Flex Layout | P1 | D | | Documents, pages, spreads | Smart guides | P0 | D | | Documents, pages, spreads | Snap to guides / grid / baseline | P0 | D | | Layers | Layers panel | P0 | D | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a97fad6..df3ff0d4b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -363,6 +363,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. - Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF. - Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds. +- Flex Layout (InDesign 2026; `flex.add`, `flex.convert`, `flex.remove`, `flex.set`, `flex.reorder`, Window › Flex Layout and the Properties panel's Flex Layout section): a frame lays out its objects in rows or columns by direction, wrap, justification (Flex Start, Center, Flex End, Space Between, Space Around, Space Evenly), the alignment grid, Fixed/Auto width and height, padding and spacing; aligned objects are detected on Add (direction, Auto, Space Between); resizing a container reflows it; dragging an object in one reorders it. Not yet: item sizing (Fixed/Auto/Fill Space per object), object styles with Flex Layout settings, IDML (a container exports as a frame with pasted-in objects), nested selection cues on the canvas. - Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`). - Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web). - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). From 4626c4b0846d7560c2d06b6fdc9542f074b2e446 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:24:38 +0200 Subject: [PATCH 098/128] Bound decompressed size and entry count of native and IDML ZIP imports Fixes #710 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/format/src/lib.rs | 126 +++++++++++++++++++++++++++++++++++--- crates/idml/src/import.rs | 94 ++++++++++++++++++++-------- crates/idml/src/tests.rs | 86 ++++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 33 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1c8910747..86393f803 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -87,22 +87,51 @@ pub fn load(bytes: &[u8]) -> Result { load_legacy_json(bytes) } +/// Most entries a `.designcraft` archive may hold. +const MAX_ENTRIES: usize = 100_000; +/// Most bytes `meta.json` or `document.json` may expand to. +const MAX_JSON_BYTES: u64 = 256 << 20; +/// Most bytes one embedded asset may expand to. +const MAX_ASSET_BYTES: u64 = 1 << 30; +/// Most bytes all the entries read from one archive may expand to together. +const MAX_TOTAL_BYTES: u64 = 4 << 30; + +/// Read entry `name` of `z`, or `None` when it is missing or unreadable. Refuses an entry that +/// expands past `limit` bytes or past what is `left` of the archive's budget, without +/// decompressing more than that: the sizes the archive claims are not trusted. +fn read_entry(z: &mut zip::ZipArchive>, name: &str, limit: u64, left: &mut u64) -> Result>, FormatError> { + let too_big = || FormatError::Io(format!("{name} is larger than the {limit}-byte limit (or the archive is larger than {MAX_TOTAL_BYTES} bytes)")); + let Ok(f) = z.by_name(name) else { return Ok(None) }; + let limit = limit.min(*left); + if f.size() > limit { + return Err(too_big()); + } + let mut v = Vec::new(); + if f.take(limit.saturating_add(1)).read_to_end(&mut v).is_err() { + return Ok(None); + } + let n = v.len() as u64; + if n > limit { + return Err(too_big()); + } + *left = left.saturating_sub(n); + Ok(Some(v)) +} + fn load_zip(bytes: &[u8]) -> Result { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; - let read = |z: &mut zip::ZipArchive>, name: &str| -> Option> { - let mut f = z.by_name(name).ok()?; - let mut v = Vec::new(); - f.read_to_end(&mut v).ok()?; - Some(v) - }; - let version = read(&mut z, "meta.json") + if z.len() > MAX_ENTRIES { + return Err(FormatError::NotOurs(format!("{} entries (at most {MAX_ENTRIES})", z.len()))); + } + let mut left = MAX_TOTAL_BYTES; + let version = read_entry(&mut z, "meta.json", MAX_JSON_BYTES, &mut left)? .and_then(|meta| serde_json::from_slice::(&meta).ok()) .and_then(|m| m.get("version").and_then(Value::as_u64)) .map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX)); if version > VERSION { return Err(FormatError::TooNew(version)); } - let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; + let json = read_entry(&mut z, "document.json", MAX_JSON_BYTES, &mut left)?.ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; upgrade_json(&mut v); let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?; @@ -114,7 +143,11 @@ fn load_zip(bytes: &[u8]) -> Result { for name in names { let Some(stem) = name.strip_prefix("assets/") else { continue }; let Some(id) = stem.split('.').next().and_then(|s| s.parse::().ok()) else { continue }; - if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) { + // Entries for assets the document doesn't have are never decompressed. + if !doc.assets.contains_key(&AssetId(id)) { + continue; + } + if let (Some(data), Some(a)) = (read_entry(&mut z, &name, MAX_ASSET_BYTES, &mut left)?, doc.assets.get_mut(&AssetId(id))) { let same = loaded.entry(fingerprint.hash_one(data.as_slice())).or_default(); let data = match same.iter().find(|d| ***d == data) { Some(d) => d.clone(), @@ -613,4 +646,79 @@ mod tests { } assert!(matches!(load(&buf.into_inner()), Err(FormatError::TooNew(99)))); } + + /// Write `len` bytes of `fill` to the current entry of `z`. + fn write_fill(z: &mut zip::ZipWriter>>, len: usize, fill: u8) { + let chunk = vec![fill; 1 << 20]; + let mut left = len; + while left > 0 { + let n = left.min(chunk.len()); + z.write_all(&chunk[..n]).unwrap(); + left -= n; + } + } + + /// Make the archive claim that entry `name` expands to `size` bytes, in both its local header + /// and its central directory record (the archive has no ZIP64 fields or data descriptors). + fn claim_size(bytes: &mut [u8], name: &str, size: u32) { + let mut patched = 0; + for i in 0..bytes.len().saturating_sub(46) { + let (size_at, len_at, name_at) = match &bytes[i..i + 4] { + b"PK\x03\x04" => (22, 26, 30), + b"PK\x01\x02" => (24, 28, 46), + _ => continue, + }; + let len = u16::from_le_bytes([bytes[i + len_at], bytes[i + len_at + 1]]) as usize; + if bytes.get(i + name_at..i + name_at + len) == Some(name.as_bytes()) { + bytes[i + size_at..i + size_at + 4].copy_from_slice(&size.to_le_bytes()); + patched += 1; + } + } + assert_eq!(patched, 2, "{name} not found in both headers"); + } + + /// A small archive whose `document.json` expands past the 256 MiB limit while claiming to + /// hold one byte is refused once the limit is reached, with an error that says so, instead of + /// being read whole into memory. + #[test] + fn oversized_document_json_is_refused() { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); + write_fill(&mut z, (256 << 20) + 1, b' '); + let mut bytes = z.finish().unwrap().into_inner(); + assert!(bytes.len() < 4 << 20, "{}", bytes.len()); + claim_size(&mut bytes, "document.json", 1); + let Err(err) = load(&bytes) else { panic!("an oversized document.json loaded") }; + assert!(err.to_string().contains("limit"), "{err}"); + } + + /// An embedded file that expands past the 1 GiB limit (and says so) is refused too. + #[test] + fn oversized_asset_is_refused() { + let mut d = Document::new(&NewDocument::default()); + let aid = png_asset(&mut d, &[1]); + place(&mut d, aid, 0.0); + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(&serde_json::to_vec(&d).unwrap()).unwrap(); + z.start_file(format!("assets/{}.png", aid.0), zip::write::SimpleFileOptions::default()).unwrap(); + write_fill(&mut z, (1 << 30) + 1, 0); + let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an oversized asset loaded") }; + assert!(err.to_string().contains("limit"), "{err}"); + } + + /// An archive with more entries than any document needs is refused before any is read. + #[test] + fn too_many_entries_are_refused() { + let d = Document::new(&NewDocument::default()); + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + z.start_file("document.json", stored).unwrap(); + z.write_all(&serde_json::to_vec(&d).unwrap()).unwrap(); + for i in 0..100_000 { + z.start_file(format!("junk/{i}"), stored).unwrap(); + } + let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an archive with 100 001 entries loaded") }; + assert!(err.to_string().contains("entries"), "{err}"); + } } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..54e1da5db 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -62,30 +62,14 @@ pub struct Imported { /// Import with a custom reader for linked files, reporting the problems that did not stop it. pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option>) -> Result { - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?; - let mut files: HashMap> = HashMap::new(); - // Normalised part name → entry name, first entry wins (zip order, so the choice is stable). - let mut lenient: HashMap = HashMap::new(); - for i in 0..zip.len() { - let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?; - if f.is_dir() { - continue; - } - let name = f.name().to_string(); - // The size is what the archive claims: don't reserve more than a sane part up front. - let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24)); - f.read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?; - lenient.entry(normalize_part_name(&name)).or_insert_with(|| name.clone()); - files.insert(name, buf); - } - let part = |name: &str| files.get(name).or_else(|| lenient.get(&normalize_part_name(name)).and_then(|n| files.get(n))); - if let Some(m) = part("mimetype") - && String::from_utf8_lossy(m).trim() != MIMETYPE + let mut pkg = Package::open(bytes)?; + if let Some(m) = pkg.part("mimetype")? + && String::from_utf8_lossy(&m).trim() != MIMETYPE { - return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(m).trim()))); + return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(&m).trim()))); } - let dm = part("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?; - let root = parse(dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?; + let dm = pkg.part("designmap.xml")?.ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?; + let root = parse(&dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?; if root.local() != "Document" { return Err(IdmlError::NotIdml(format!("designmap root is <{}>", root.name))); } @@ -95,11 +79,11 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option Option { + zip: zip::ZipArchive>, + /// Normalised part name → entry name, first entry wins (zip order, so the choice is stable). + lenient: HashMap, + /// Bytes the parts still to be read may expand to. + left: u64, +} + +impl<'a> Package<'a> { + fn open(bytes: &'a [u8]) -> Result { + let zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?; + if zip.len() > MAX_ENTRIES { + return Err(IdmlError::NotIdml(format!("{} entries (at most {MAX_ENTRIES})", zip.len()))); + } + let mut lenient: HashMap = HashMap::new(); + for i in 0..zip.len() { + let Some(name) = zip.name_for_index(i) else { continue }; + if name.ends_with('/') { + continue; + } + lenient.entry(normalize_part_name(name)).or_insert_with(|| name.to_string()); + } + Ok(Package { zip, lenient, left: MAX_TOTAL_BYTES }) + } + + /// The bytes of part `name` (matched exactly, then leniently), or `None` if the package has + /// no such part. + fn part(&mut self, name: &str) -> Result>> { + let exact = self.zip.index_for_name(name).filter(|_| !name.ends_with('/')); + let Some(i) = exact.or_else(|| self.lenient.get(&normalize_part_name(name)).and_then(|n| self.zip.index_for_name(n))) else { + return Ok(None); + }; + let f = self.zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?; + let part = f.name().to_string(); + let limit = MAX_PART_BYTES.min(self.left); + let too_big = || IdmlError::Part { + part: part.clone(), + msg: format!("larger than the {MAX_PART_BYTES}-byte part limit (or the package is larger than {MAX_TOTAL_BYTES} bytes)"), + }; + if f.size() > limit { + return Err(too_big()); + } + let mut buf = Vec::new(); + f.take(limit.saturating_add(1)).read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: part.clone(), msg: e.to_string() })?; + let n = buf.len() as u64; + if n > limit { + return Err(too_big()); + } + self.left = self.left.saturating_sub(n); + Ok(Some(buf)) + } +} + /// A package part name in the form the lookup compares: InDesign and other IDML writers differ /// in percent-encoding, case, path separators and a leading `./` or `/`. fn normalize_part_name(name: &str) -> String { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 57801e34f..4cc273ca1 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2169,3 +2169,89 @@ fn frame_preference_property_color_resolves_for_frames_and_object_styles() { assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); } + +/// A package entry nothing in `designmap.xml` refers to is never decompressed: a damaged one +/// (its checksum no longer matches) does not stop the import. +#[test] +fn unreferenced_entries_are_not_read() { + use zip::write::SimpleFileOptions; + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + w.start_file("mimetype", stored).unwrap(); + w.write_all(MIMETYPE.as_bytes()).unwrap(); + for (n, c) in [ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ] { + w.start_file(n, SimpleFileOptions::default()).unwrap(); + w.write_all(c.as_bytes()).unwrap(); + } + w.start_file("junk/irrelevant.bin", stored).unwrap(); + w.write_all(b"<>").unwrap(); + let mut bytes = w.finish().unwrap().into_inner(); + let at = bytes.windows(9).position(|w| w == b"< + +
    "#; + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + w.start_file("mimetype", SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored)).unwrap(); + w.write_all(MIMETYPE.as_bytes()).unwrap(); + w.start_file("designmap.xml", SimpleFileOptions::default()).unwrap(); + w.write_all(designmap.as_bytes()).unwrap(); + w.start_file("Stories/Story_big.xml", SimpleFileOptions::default()).unwrap(); + let chunk = vec![b' '; 1 << 20]; + for _ in 0..512 { + w.write_all(&chunk).unwrap(); + } + w.write_all(b" ").unwrap(); + let mut bytes = w.finish().unwrap().into_inner(); + assert!(bytes.len() < 8 << 20, "{}", bytes.len()); + // Claim one byte in the local header and the central directory record (no ZIP64 fields or + // data descriptors here), so only the bytes actually decompressed can show the part is too big. + let mut patched = 0; + for i in 0..bytes.len().saturating_sub(46) { + let (size_at, len_at, name_at) = match &bytes[i..i + 4] { + b"PK\x03\x04" => (22, 26, 30), + b"PK\x01\x02" => (24, 28, 46), + _ => continue, + }; + let len = u16::from_le_bytes([bytes[i + len_at], bytes[i + len_at + 1]]) as usize; + if bytes.get(i + name_at..i + name_at + len) == Some(b"Stories/Story_big.xml".as_slice()) { + bytes[i + size_at..i + size_at + 4].copy_from_slice(&1u32.to_le_bytes()); + patched += 1; + } + } + assert_eq!(patched, 2); + let Err(err) = import_idml(&bytes) else { panic!("an oversized part imported") }; + assert!(err.to_string().contains("limit"), "{err}"); +} + +/// A package with more entries than any document needs is refused before any is read. +#[test] +fn too_many_entries_are_refused() { + use zip::write::SimpleFileOptions; + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + w.start_file("mimetype", stored).unwrap(); + w.write_all(MIMETYPE.as_bytes()).unwrap(); + for i in 0..100_000 { + w.start_file(format!("junk/{i}"), stored).unwrap(); + } + let Err(err) = import_idml(&w.finish().unwrap().into_inner()) else { panic!("a package with 100 001 entries imported") }; + assert!(err.to_string().contains("entries"), "{err}"); +} From 89f6adb6fbc3d9d5a19d0be177c19daede7794d0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 01:25:04 +0200 Subject: [PATCH 099/128] Find Font: a style that differs only in spacing isn't missing Montilla Design's Flex Layout file names its body face "Regular " (with a trailing space, as the designer's copy of Articulat CF did). Face lookup already ignores spacing and case, so the text is drawn in Regular, but font.list (Find Font) compared names exactly and reported the style missing. It now uses the lookup's comparison (designcraft_fonts::same_style). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/fonts.rs | 20 +++++++++++++++++++- crates/fonts/src/fontdb.rs | 6 ++++++ crates/fonts/src/lib.rs | 2 +- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..0e9bd51a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Find Font no longer reports a style missing when it differs from the installed one only in spacing or case (InDesign files can name `Regular ` with a trailing space); the text was already drawn in the right face. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 3b173c199..f119dd087 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -73,7 +73,7 @@ fn list(d: &Document) -> Vec { .into_iter() .map(|((family, style), n)| { let missing = !db.has_family(&family); - let style_missing = !missing && !db.styles(&family).iter().any(|s| s.eq_ignore_ascii_case(&style)); + let style_missing = !missing && !db.styles(&family).iter().any(|s| designcraft_fonts::same_style(s, &style)); let source = (!missing).then(|| match db.face(&family, &style).source { FontSource::Bundled => "bundled", FontSource::Installed(_) => "installed", @@ -225,4 +225,22 @@ mod tests { assert_eq!((&l[0]["family"], &l[0]["missing"]), (&json!("Nope (OTF)"), &json!(true)), "{l}"); assert!(kinds(&mut s).iter().any(|k| k == "missingFont")); } + + /// A style name that differs only in spacing (Montilla Design's file sets `Regular ` with a + /// trailing space) is the face the text is drawn in, not a missing style. + #[test] + fn a_style_with_extra_spacing_is_not_missing() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Text"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Source Serif 4", "fontStyle": "Regular "})).unwrap(); + let l = s.execute("font.list", &json!({})).unwrap(); + let entry = l.as_array().unwrap().iter().find(|f| f["style"] == "Regular ").unwrap(); + assert_eq!((&entry["missing"], &entry["styleMissing"]), (&json!(false), &json!(false)), "{l}"); + s.execute("type.char", &json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"})).unwrap(); + let l = s.execute("font.list", &json!({})).unwrap(); + assert_eq!(l[0]["styleMissing"], json!(true), "{l}"); + } } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index bc9c9e65f..0ee10a6b4 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -845,6 +845,12 @@ pub fn base_style(style: &str) -> &str { style.split('{').next().unwrap_or(style).trim() } +/// Do two style names name the same face? Spacing, punctuation and case don't count, as when a face +/// is looked up (InDesign documents can carry `Regular ` with a trailing space). +pub fn same_style(a: &str, b: &str) -> bool { + norm(a) == norm(b) +} + fn norm(s: &str) -> String { s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 5ff395df8..088fba02c 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -16,7 +16,7 @@ mod vertical; pub use fontdb::{ DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, - MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, system_font_dirs, + MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, same_style, system_font_dirs, }; pub use group::{FamilyInfo, FontGroup, sort_for_menu}; pub use harfrust::Feature; From e35c430628ae1203da82177d8c785b1da4254fbf Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:30:58 +1000 Subject: [PATCH 100/128] Preserve stored IDML page liquid rules Co-Authored-By: Codex --- crates/idml/src/export.rs | 1 + crates/idml/src/import.rs | 2 +- crates/idml/src/names.rs | 25 ++++++ crates/idml/src/tests.rs | 156 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 1 deletion(-) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0b9435c71..d699268e4 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1663,6 +1663,7 @@ impl<'a> Ex<'a> { .attr("Self", uid(pg.id.0)) .attr("Name", name) .attr("AppliedMaster", applied.map(|s| uid(s.0)).unwrap_or_else(|| "n".into())) + .attr("LayoutRule", names::liquid_rule_out(pg.liquid)) .attr("OverrideList", "") .attr("GeometricBounds", format!("0 0 {} {}", num(pg.height), num(pg.width))) .attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy))) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..04e3bbec2 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2015,7 +2015,7 @@ impl<'r> Importer<'r> { overridden: vec![], guides: Vec::::new(), show_parent_items: show, - liquid: Default::default(), + liquid: names::liquid_rule_in(p.get("LayoutRule").unwrap_or("Off")), transition: None, view_rotation: 0, show_parent_overlay: false, diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index ed0daaf40..d83a55884 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -74,6 +74,31 @@ pub fn fitting_in(s: &str) -> designcraft_doc::Fitting { } } +pub fn liquid_rule_out(rule: designcraft_doc::LiquidRule) -> &'static str { + use designcraft_doc::LiquidRule as R; + match rule { + R::Off => "Off", + R::Scale => "Scale", + R::ReCenter => "Recenter", + R::GuideBased => "GuideBased", + R::ObjectBased => "ObjectBased", + R::ControlledByParent => "UseMaster", + } +} + +pub fn liquid_rule_in(s: &str) -> designcraft_doc::LiquidRule { + use designcraft_doc::LiquidRule as R; + match s { + "Off" => R::Off, + "Scale" => R::Scale, + "Recenter" => R::ReCenter, + "GuideBased" => R::GuideBased, + "ObjectBased" => R::ObjectBased, + "UseMaster" => R::ControlledByParent, + _ => R::Off, + } +} + pub fn escape_id(s: &str) -> String { s.replace('%', "%25").replace(':', "%3a") } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 57801e34f..a5da73992 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2169,3 +2169,159 @@ fn frame_preference_property_color_resolves_for_frames_and_object_styles() { assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); } + +mod liquid_page_rules { + use super::*; + use designcraft_doc::LiquidRule as R; + use std::sync::Arc; + + // Literal XML expectations, independent of names.rs and serde spellings. + const CASES: [(&str, R); 6] = [ + ("Scale", R::Scale), + ("Off", R::Off), + ("Recenter", R::ReCenter), + ("GuideBased", R::GuideBased), + ("ObjectBased", R::ObjectBased), + ("UseMaster", R::ControlledByParent), + ]; + + fn fixture(rule: Option<&str>, parent_rule: Option<&str>) -> Vec { + let attribute = |value: Option<&str>| value.map(|v| format!(r#" LayoutRule="{v}""#)).unwrap_or_default(); + let (rule, parent_rule) = (attribute(rule), attribute(parent_rule)); + let map = r#" + + + + +"#; + let parent = format!( + r#" + + + + +"# + ); + // Based-on belongs to the whole derived master: both pages apply the same master. + let derived = format!( + r#" + + + + +"# + ); + let spread = format!( + r#" + + + + + + +"# + ); + zip_files(&[ + ("designmap.xml", map), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/A.xml", &parent), + ("MasterSpreads/B.xml", &derived), + ("Spreads/S.xml", &spread), + ]) + } + + #[test] + fn import_literal_xml() { + let baseline = import_idml(&fixture(None, None)).unwrap(); + assert_eq!((baseline.spreads.len(), baseline.parents.len()), (1, 2)); + assert!(baseline.spreads.iter().chain(&baseline.parents).all(|sp| sp.pages.len() == 2)); + assert!(baseline.spreads.iter().chain(&baseline.parents).flat_map(|sp| &sp.pages).all(|pg| pg.liquid == R::Off)); + let cases = CASES.into_iter().map(|(literal, rule)| (Some(literal), rule)).chain([ + (None, R::Off), + (Some("ReCenter"), R::Off), + (Some("controlledByParent"), R::Off), + (Some("PreserveExisting"), R::Off), + ]); + for (literal, rule) in cases { + let actual = import_idml(&fixture(literal, Some("Recenter"))).unwrap(); + assert_eq!(actual.spreads[0].pages[1].liquid, rule, "ordinary Page: {literal:?}"); + assert_eq!(actual.parents[1].pages[0].liquid, rule, "master Page: {literal:?}"); + // Same XML without rule attributes supplies the complete allowed-delta oracle. + let mut expected = baseline.clone(); + Arc::make_mut(&mut expected.spreads[0]).pages[1].liquid = rule; + Arc::make_mut(&mut expected.parents[1]).pages[0].liquid = rule; + Arc::make_mut(&mut expected.parents[0]).pages[1].liquid = R::ReCenter; + // Import assigns a fresh creation timestamp; all other document fields are compared. + expected.created = actual.created; + assert_eq!(actual, expected, "{literal:?}: only the three explicit rule fields may change apart from the import timestamp"); + assert_eq!(actual.spreads[0].pages[0].liquid, R::Off, "omitted does not infer inheritance"); + assert_eq!(actual.spreads[0].pages[0].parent, Some(actual.parents[0].id)); + assert_eq!(actual.parents[1].parent.as_ref().unwrap().based_on, Some(actual.parents[0].id)); + assert!(actual.parents.iter().flat_map(|sp| &sp.pages).all(|pg| pg.parent.is_none())); + if rule == R::ControlledByParent { + // The right document page resolves to A's right page, not B's controlled page. + assert_eq!(actual.parent_page_for(1), Some((0, 1))); + assert_eq!(actual.parents[0].pages[1].liquid, R::ReCenter); + } + } + } + + #[test] + fn export_and_round_trip() { + use std::io::Read; + + let baseline = import_idml(&fixture(None, None)).unwrap(); + for (literal, rule) in CASES { + // Set native enums directly; the literal importer under test does not build this input. + let mut source = baseline.clone(); + let pages = &mut Arc::make_mut(&mut source.spreads[0]).pages; + pages[0].parent = None; + pages[0].liquid = R::ControlledByParent; + pages[1].liquid = rule; + Arc::make_mut(&mut source.parents[0]).pages[1].liquid = R::ReCenter; + Arc::make_mut(&mut source.parents[1]).pages[0].liquid = rule; + source.check().unwrap(); + let bytes = export_idml(&source); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + for (directory, tag, spread, rules) in [ + ("Spreads", "Spread", &source.spreads[0], ["UseMaster", literal]), + ("MasterSpreads", "MasterSpread", &source.parents[0], ["Off", "Recenter"]), + ("MasterSpreads", "MasterSpread", &source.parents[1], [literal, "Off"]), + ] { + let path = format!("{directory}/{tag}_u{:x}.xml", spread.id.0); + let mut part = Vec::new(); + archive.by_name(&path).unwrap().read_to_end(&mut part).unwrap(); + let root = xml::parse(&part).unwrap(); + let body = root.find(tag).unwrap(); + let pages: Vec<_> = body.find_all("Page").collect(); + assert_eq!(pages.len(), spread.pages.len(), "{path}"); + for (page, expected) in spread.pages.iter().zip(rules) { + let id = format!("u{:x}", page.id.0); + let matches: Vec<_> = pages.iter().filter(|p| p.get("Self") == Some(id.as_str())).collect(); + assert_eq!(matches.len(), 1, "{path}: {id}"); + assert_eq!(matches[0].get("LayoutRule"), Some(expected), "{path}: {id}"); + } + } + let actual = import_idml(&bytes).unwrap(); + assert_eq!((actual.spreads.len(), actual.parents.len()), (1, 2)); + assert_eq!(actual.spreads[0].pages[0].parent, None); + assert_eq!(actual.spreads[0].pages[1].parent, Some(actual.parents[0].id)); + assert_eq!(actual.parent_page_for(1), Some((0, 1))); + assert!(actual.parents.iter().flat_map(|sp| &sp.pages).all(|pg| pg.parent.is_none())); + for (index, prefix, name, based_on) in [(0, "A", "Base", None), (1, "B", "Derived", Some(actual.parents[0].id))] { + let info = actual.parents[index].parent.as_ref().unwrap(); + assert_eq!((&info.prefix[..], &info.name[..], info.based_on), (prefix, name, based_on)); + } + for (got, want) in actual.spreads.iter().chain(&actual.parents).zip(source.spreads.iter().chain(&source.parents)) { + assert_eq!(got.pages.len(), want.pages.len()); + for (got, want) in got.pages.iter().zip(&want.pages) { + // IDs are allocated afresh; parent associations were checked explicitly above. + let mut normalized = got.clone(); + normalized.id = want.id; + normalized.parent = want.parent; + assert_eq!(&normalized, want, "{literal}: all other Page fields, including the stored rule"); + } + } + } + } +} From 2dcef6c951cea7a8638ef930cad8edf6827ee39e Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Thu, 15 Oct 2026 03:38:25 +0400 Subject: [PATCH 101/128] i18n(ka): bring the Georgian catalog up to date Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index bf0e67201..8350f2c53 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1784,4 +1784,41 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Stop Preview", "წინასწარი ნახვის შეწყვეტა"), ("Update Data Source", "მონაცემთა წყაროს განახლება"), ("Validate from Root Element", "ძირეული ელემენტიდან შემოწმება"), + ("Duplicate Spread at End of Document", "გაშლის დუბლირება დოკუმენტის ბოლოში"), + ("Delete Spread", "გაშლის წაშლა"), + ("Parent Pages", "საბაზო გვერდები"), + ("Delete selected pages", "მონიშნული გვერდების წაშლა"), + ("Create new page", "ახალი გვერდის შექმნა"), + ("Edit page size", "გვერდის ზომის რედაქტირება"), + ("Custom Page Size…", "გვერდის მორგებული ზომა…"), + ("Page:", "გვერდი:"), + ("Content:", "შიგთავსი:"), + ("Object Constraints:", "ობიექტის შეზღუდვები:"), + ("Resize with Page:", "ზომის შეცვლა გვერდთან ერთად:"), + ("Pin:", "სამაგრი:"), + ("Object-Based", "ობიექტებზე დაფუძნებული"), + ("Guide-Based", "დამხმარე ხაზებზე დაფუძნებული"), + ("Resize Page", "გვერდის ზომის შეცვლა"), + ("Liquid Page Rule:", "გვერდის მოქნილი წესი:"), + ("Show Parent Page Overlay", "საბაზო გვერდის გადაფარვის ჩვენება"), + ("Controlled by Parent", "იმართება საბაზო გვერდიდან"), + ("US Business Card", "სავიზიტო ბარათი (აშშ)"), + ("Object is pinned relative to the page edge. Click to release.", "ობიექტი მიმაგრებულია გვერდის კიდესთან. დაწკაპეთ გასათავისუფლებლად."), + ("Click to pin the object relative to the page edge.", "დაწკაპეთ ობიექტის გვერდის კიდესთან მისამაგრებლად."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "ობიექტის სიგანე გვერდის სიგანესთან ერთად იცვლება. დაწკაპეთ ობიექტის სიგანის ჩასაკეტად.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "ობიექტის სიგანე ჩაკეტილია. დაწკაპეთ, რომ ობიექტის სიგანე გვერდის სიგანესთან ერთად შეიცვალოს.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "ობიექტის სიმაღლე გვერდის სიმაღლესთან ერთად იცვლება. დაწკაპეთ ობიექტის სიმაღლის ჩასაკეტად.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "ობიექტის სიმაღლე ჩაკეტილია. დაწკაპეთ, რომ ობიექტის სიმაღლე გვერდის სიმაღლესთან ერთად შეიცვალოს.", + ), ]; From ccf4f4aee4bd2af0bd1b132388dbc04e11c11df9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 01:40:50 +0200 Subject: [PATCH 102/128] An empty last paragraph isn't overset text, as in InDesign A story that ends with a paragraph return has an empty last paragraph. When it didn't fit, DesignCraft marked the story overset at the end of its text (Preflight: "Overset text: 0 characters"; the out port turned red). InDesign doesn't: Montilla Design's Flex Layout file has a caption ending with a return, fitted to its three lines, and InDesign reports no errors. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 5 +++++ crates/compose/src/tests.rs | 16 ++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..a800b9078 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · A story that ends with a return is no longer overset when only its empty last paragraph doesn't fit, as in InDesign (Preflight said "Overset text: 0 characters" for a caption InDesign shows with no errors). No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c6312e9f5..ec7d6d30d 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -936,6 +936,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C pi = j; continue 'paras; } + // An empty last paragraph (the story ends with a return) isn't overset text, as in + // InDesign: nothing of it is left to show. + if prange.is_empty() && prange.start >= story.text.len() { + break 'paras; + } out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start)); // The part of the paragraph that fits keeps its shading and border. para_box_decos(&mut out, pi, &pp); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a20ec78b3..b226c15fd 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -4060,3 +4060,19 @@ fn empty_paragraphs_get_no_bullet_or_number() { assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); } } + +/// A story that ends with a return isn't overset when only its empty last paragraph doesn't fit, +/// as in InDesign (Montilla Design's file: a caption fitted to its three lines, "No errors"). Text +/// that doesn't fit still is. +#[test] +fn an_empty_last_paragraph_isnt_overset() { + // 12 pt, 14.4 leading: two lines fit in 35 pt, a third (baseline ~40) doesn't. + let (d, sid, _) = doc_with("One\nTwo\n", Rect::new(0.0, 0.0, 200.0, 35.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(all_lines(&cs).len(), 2); + assert!(!cs.is_overset(), "only the empty paragraph after the last return is left"); + let (d, sid, _) = doc_with("One\nTwo\nThree", Rect::new(0.0, 0.0, 200.0, 35.0), ParaAttrs::default()); + assert!(compose_story(&d, sid, &ComposeOptions::default()).is_overset()); + let (d, sid, _) = doc_with("One\nTwo\n\n", Rect::new(0.0, 0.0, 200.0, 35.0), ParaAttrs::default()); + assert!(compose_story(&d, sid, &ComposeOptions::default()).is_overset(), "an empty paragraph before the last one is text"); +} From edfc98ffd702300ff5f361389a3077c0f057d365 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 22:05:13 -0400 Subject: [PATCH 103/128] fix: integrate batched community PRs (main's new tests with #559's FrameSpec.section_marker and #604's load_document_fonts guard) Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 1 + crates/compose/src/tests.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index cef3b674f..3008eac84 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3749,6 +3749,7 @@ mod column_allocation_safety_tests { vertical: false, exclusions: vec![], page_name: None, + section_marker: None, page: None, grid: None, left_page: false, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e38e236dd..ece34fc59 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3577,7 +3577,7 @@ fn drop_cap_in_a_document_font_aligns_its_left_edge() { let folder = std::env::temp_dir().join(format!("dc-compose-dropcap-docfont-{}", std::process::id())); std::fs::create_dir_all(&folder).unwrap(); std::fs::write(folder.join("dropcap.ttf"), designcraft_fonts::testing::font_with_glyph(FAMILY, &chars, 'O').unwrap()).unwrap(); - let scope = designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope; + let scope = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|_| Ok(())).scope; let para = ParaAttrs { left_indent: Some(10.0), ..drop_cap(2, 1) }; let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para); d.font_scope = scope; From 8da5bb5bab6e7be6c83c3402b4d45b8fab92474b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 22:26:51 -0400 Subject: [PATCH 104/128] =?UTF-8?q?fix:=20integrate=20batched=20community?= =?UTF-8?q?=20PRs=20(pt-BR/Arabic=20catalogs=20drop=20keys=20main=20alread?= =?UTF-8?q?y=20translates;=20Typography=20in=20Window=20=E2=80=BA=20Type?= =?UTF-8?q?=20&=20Tables;=20Georgian=20for=20the=20new=20menu=20labels)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ar.rs | 5 ----- crates/ui-egui/src/i18n/ka.rs | 5 +++++ crates/ui-egui/src/i18n/pt_br.rs | 11 ----------- crates/ui-egui/src/menus.rs | 7 +++++-- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 4cdcacbbe..eae948581 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1549,7 +1549,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("All records", "كل السجلات"), ("Alpha Channel", "قناة ألفا"), ("Anchored Object Options", "خيارات الكائن المرسّى"), - ("Apply Parent to Pages", "تطبيق الأصل على الصفحات"), ("ArtCraft App Page", "صفحة تطبيق ArtCraft"), ("ArtCraft Website", "موقع ArtCraft"), ("Bind to frame", "ربط بالإطار"), @@ -1567,12 +1566,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Column Break", "فاصل عمود"), ("Column spacing", "تباعد الأعمدة"), ("Columns first", "الأعمدة أولاً"), - ("Columns:", "الأعمدة:"), ("Conditional Text", "نص شرطي"), ("Copyright Symbol", "رمز حقوق النشر"), ("Corner Options", "خيارات الزوايا"), ("Create Guides", "إنشاء أدلة"), - ("Current Page Number", "رقم الصفحة الحالية"), ("Cyan", "سماوي"), ("Dark", "داكن"), ("Data Merge", "دمج البيانات"), @@ -1624,7 +1621,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Hair Space", "مسافة شعرية"), ("High Contrast", "تباين عالٍ"), ("High Resolution", "دقة عالية"), - ("Horizontal:", "أفقي:"), ("I, II, III, IV...", "أ، ب، ج، د..."), ("Import XML", "استيراد XML"), ("Indent to Here", "إزاحة بادئة إلى هنا"), @@ -1766,7 +1762,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Type/Type on a Path", "نص/النص على مسار"), ("URL:", "URL:"), ("Unassigned", "غير معيّن"), - ("Vertical:", "رأسي:"), ("View/Display Performance", "عرض/أداء العرض"), ("View/Grids & Guides", "عرض/الشبكات والأدلة"), ("Window/Arrange", "نافذة/ترتيب"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 8350f2c53..7b8614369 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1821,4 +1821,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "The object's height is locked. Click to let the object change height as the page changes height.", "ობიექტის სიმაღლე ჩაკეტილია. დაწკაპეთ, რომ ობიექტის სიმაღლე გვერდის სიმაღლესთან ერთად შეიცვალოს.", ), + ("Bulleted & Numbered Lists", "მარკირებული და დანომრილი სიები"), + ("Styles", "სტილები"), + ("Object & Layout", "ობიექტი და განლაგება"), + ("Editorial", "სარედაქციო"), + ("Essentials Classic", "ძირითადი (კლასიკური)"), ]; diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 583371a7a..223a350a9 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1157,7 +1157,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Tabs", "Tabulações"), ("Story", "Matéria"), ("Create Outlines", "Criar contornos"), - ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Unmerge Cells", "Desfazer mesclagem de células"), ("Snap to Guides", "Alinhar às guias"), ("Snap to Document Grid", "Alinhar à grade do documento"), @@ -1185,7 +1184,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Add Hyphenation Exception", "Adicionar exceção de hifenização"), ("Add Item", "Adicionar item"), ("Add Page to Spread", "Adicionar página ao spread"), - ("Allow Selected Spread to Shuffle", "Permitir que o spread selecionado seja reorganizado"), ("Anchored Object Options…", "Opções de objeto ancorado…"), ("Anchored Objects", "Objetos ancorados"), ("Apply Attributes (Eyedropper)", "Aplicar atributos (conta-gotas)"), @@ -1225,7 +1223,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert to Form Field", "Converter em campo de formulário"), ("Copy Link(s) To…", "Copiar link(s) para…"), ("Corner Options…", "Opções de canto…"), - ("Create Alternate Layout…", "Criar layout alternativo…"), ("Create Frame", "Criar quadro"), ("Create Merged Document…", "Criar documento mesclado…"), ("Cross-Reference Formats", "Formatos de referência cruzada"), @@ -1241,7 +1238,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Delete Workspace…", "Excluir espaço de trabalho…"), ("DesignCraft Website…", "Site do DesignCraft…"), ("DesignCraft on GitHub…", "DesignCraft no GitHub…"), - ("Detach All Objects from Parent", "Desvincular todos os objetos da página-mestre"), ("Directional Feather", "Esmaecimento direcional"), ("Distribute", "Distribuir"), ("Dock Panel", "Encaixar painel"), @@ -1289,9 +1285,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Inner Shadow", "Sombra interna"), ("Insert Anchored Object…", "Inserir objeto ancorado…"), ("Insert Cross-Reference…", "Inserir referência cruzada…"), - ("Insert Endnote", "Inserir nota de fim"), ("Insert Math Expression…", "Inserir expressão matemática…"), - ("Insert Pages…", "Inserir páginas…"), ("Insert Variable", "Inserir variável"), ("Inspect Document", "Inspecionar documento"), ("Interface Color Theme", "Tema de cores da interface"), @@ -1340,7 +1334,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Open Library", "Abrir biblioteca"), ("Outer Glow", "Brilho externo"), ("Overprint Preview", "Visualização de sobreimpressão"), - ("Override All Parent Page Items", "Substituir todos os itens da página-mestre"), ("Page Transition", "Transição de página"), ("Page Transitions", "Transições de página"), ("Paragraph Formatting", "Formatação de parágrafo"), @@ -1363,7 +1356,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Recovery Data", "Dados de recuperação"), ("Reject All Changes", "Rejeitar todas as alterações"), ("Release Compound Path", "Liberar traçado composto"), - ("Remove All Local Overrides", "Remover todas as substituições locais"), ("Remove Document", "Remover documento"), ("Report an Issue…", "Relatar um problema…"), ("Reset Shortcuts", "Redefinir atalhos"), @@ -1487,14 +1479,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Column", "Mover coluna"), ("Move Guide", "Mover guia"), ("Move Layer", "Mover camada"), - ("Move Pages…", "Mover páginas…"), ("Move to Layer", "Mover para a camada"), ("New Article", "Novo artigo"), ("New Bookmark", "Novo marcador"), ("New Layer…", "Nova camada…"), - ("New Note", "Nova nota"), ("New Object Style…", "Novo estilo de objeto…"), - ("New Parent…", "Nova página-mestre…"), ("New Tag", "Nova tag"), ("Paste in Place", "Colar no mesmo lugar"), ("Place Loaded Graphic", "Inserir gráfico carregado"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2a4690326..86c2088e8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -777,6 +777,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", "ui:window.panel|Paragraph|{\"panel\": \"paragraph\"}", "ui:window.panel|Table|{\"panel\": \"table\"}", + "ui:window.panel|Typography|{\"panel\": \"typography\"}", "<", ">Utilities", "ui:window.panel|Book|{\"panel\": \"book\"}", @@ -2993,8 +2994,10 @@ mod tests { _ => {} } } - // Labels without letters ("180°") read the same in every language. - untranslated.extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && crate::i18n::tr("ka", l) == l.as_str())); + // Labels without letters ("180°") or point sizes ("72 pt") read the same in every language. + let size = |l: &str| l.strip_suffix(" pt").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); + untranslated + .extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && !size(l) && crate::i18n::tr("ka", l) == l.as_str())); } assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}"); } From 8a98451d2d564fbe938c9bc411d707b8efbcbb4a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 22:49:37 -0400 Subject: [PATCH 105/128] fix(flex): flex.reorder clamps a huge `by` instead of overflowing (#973) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/flex.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/flex.rs b/crates/engine/src/cmd/flex.rs index bdb7f73e8..e8ef451ab 100644 --- a/crates/engine/src/cmd/flex.rs +++ b/crates/engine/src/cmd/flex.rs @@ -378,7 +378,7 @@ fn reorder(s: &mut Session, p: &Value) -> Result { let Content::Group { items } = &mut c.content else { return Err(bad("flex.reorder", "the object isn't in a Flex Layout")) }; let n = items.len() as i64; let from = *last as i64; - let target = to.unwrap_or(from + by.unwrap_or(0)).clamp(0, n - 1); + let target = to.unwrap_or(from.saturating_add(by.unwrap_or(0))).clamp(0, n - 1); let moved = items.remove(from as usize); items.insert(target as usize, moved); *sel = Selection::items(vec![id]); @@ -428,6 +428,16 @@ mod tests { s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds() } + #[test] + fn reorder_by_a_huge_step_clamps_instead_of_overflowing() { + let (mut s, ids) = session_with_boxes(&[[84.0, 84.0, 184.0, 184.0], [284.0, 84.0, 384.0, 184.0]]); + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("flex.add", &json!({})).unwrap(); + // `from + by` used to overflow (a panic with overflow checks on). + s.execute("flex.reorder", &json!({"id": ids[0], "by": i64::MAX})).unwrap(); + s.execute("flex.reorder", &json!({"id": ids[0], "by": i64::MIN})).unwrap(); + } + /// The video: four objects in a row become a container that keeps them where they are (Row, /// Auto size, Space Between at their spacing, aligned to the top), then a fixed 2-column layout. #[test] From 26b5578291b0229b6d0f960740c3f55a24b253e2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:58:52 +0200 Subject: [PATCH 106/128] Skip .ase swatches with NaN or infinite colour components Found while testing crates/color/src/ase.rs:83 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/color/src/ase.rs | 57 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/color/src/ase.rs b/crates/color/src/ase.rs index ecae906fd..c20c249ec 100644 --- a/crates/color/src/ase.rs +++ b/crates/color/src/ase.rs @@ -69,7 +69,7 @@ pub fn read(bytes: &[u8]) -> Result, AseError> { r.take(4)?; // version let blocks = r.u32()?; let (mut out, mut colors) = (Vec::new(), 0usize); - for _ in 0..blocks { + 'blocks: for _ in 0..blocks { let kind = r.u16()?; let len = r.u32()? as usize; let body = r.take(len)?; @@ -84,6 +84,19 @@ pub fn read(bytes: &[u8]) -> Result, AseError> { let mut b = R { b: body, i: 0 }; let name = b.name()?; let model = b.take(4)?.to_vec(); + let n = match &model[..] { + b"CMYK" => 4, + b"RGB " | b"LAB " => 3, + b"Gray" => 1, + _ => continue, + }; + // A NaN or infinite component cannot be stored in a document: leave that swatch out. + let mut peek = R { b: body, i: b.i }; + for _ in 0..n { + if !peek.f32()?.is_finite() { + continue 'blocks; + } + } let color = match &model[..] { b"CMYK" => Color::Cmyk { c: b.f32()?, m: b.f32()?, y: b.f32()?, k: b.f32()? }, b"RGB " => Color::Rgb { r: b.f32()?, g: b.f32()?, b: b.f32()? }, @@ -91,6 +104,9 @@ pub fn read(bytes: &[u8]) -> Result, AseError> { b"LAB " => { let (l, a, bb) = (b.f32()?, b.f32()?, b.f32()?); let [r, g, bl] = cms::lab::lab_to_srgb(cms::Lab { l: l * 100.0, a, b: bb }); + if ![r, g, bl].iter().all(|v| v.is_finite()) { + continue; + } Color::Rgb { r: r.clamp(0.0, 1.0), g: g.clamp(0.0, 1.0), b: bl.clamp(0.0, 1.0) } } _ => continue, @@ -214,4 +230,43 @@ mod tests { assert_eq!(read(&write(&sw)).err(), Some(AseError::TooManyColors)); assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit"); } + + #[test] + fn non_finite_components_are_skipped() { + let sw = |name: &str, color: Color| Swatch { + name: name.into(), + value: SwatchValue::Color { color, color_type: ColorType::Process }, + locked: false, + named: true, + hidden: false, + }; + let mut list = vec![sw("Ok", Color::Rgb { r: 0.1, g: 0.2, b: 0.3 })]; + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + list.push(sw("Rgb", Color::Rgb { r: bad, g: 0.0, b: 0.0 })); + list.push(sw("Cmyk", Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: bad })); + list.push(sw("Gray", Color::Gray { k: bad })); + } + let mut bytes = write(&list); + // Append Lab blocks with a non-finite component. + let mut extra = 0u32; + for bad in [f32::NAN, f32::INFINITY] { + let mut c = Vec::new(); + c.extend(2u16.to_be_bytes()); + c.extend([0, b'L', 0, 0]); + c.extend(b"LAB "); + for v in [bad, 0.0, 0.0] { + c.extend(v.to_bits().to_be_bytes()); + } + c.extend(2u16.to_be_bytes()); + bytes.extend(COLOR.to_be_bytes()); + bytes.extend((c.len() as u32).to_be_bytes()); + bytes.extend(&c); + extra += 1; + } + let count = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) + extra; + bytes[8..12].copy_from_slice(&count.to_be_bytes()); + let back = read(&bytes).unwrap(); + let names: Vec<&str> = back.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Ok"], "non-finite swatches must be left out"); + } } From 0faf63b92b87147f539b7e10f12331f1d6961c9d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 23:00:55 -0400 Subject: [PATCH 107/128] =?UTF-8?q?fix:=20integrate=20batched=20community?= =?UTF-8?q?=20PRs=20(#973's=20Flex=20Layout=20panel=20in=20Window=20?= =?UTF-8?q?=E2=80=BA=20Object=20&=20Layout;=20Georgian=20for=20its=20menu?= =?UTF-8?q?=20labels)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 4 ++++ crates/ui-egui/src/menus.rs | 1 + 2 files changed, 5 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 7b8614369..350ea526e 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1826,4 +1826,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object & Layout", "ობიექტი და განლაგება"), ("Editorial", "სარედაქციო"), ("Essentials Classic", "ძირითადი (კლასიკური)"), + ("Add to Flex Layout", "მოქნილ განლაგებაში დამატება"), + ("Convert to Flex Layout", "მოქნილ განლაგებად გარდაქმნა"), + ("Remove Flex Layout", "მოქნილი განლაგების წაშლა"), + ("Show Flex Controls", "მოქნილი მართვის ელემენტების ჩვენება"), ]; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ec0b7e5dd..bf2626099 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -771,6 +771,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Links|{\"panel\": \"links\"}", ">Object & Layout", "ui:window.panel|Align|{\"panel\": \"align\"}", + "ui:window.panel|Flex Layout|{\"panel\": \"flexLayout\"}", "ui:window.panel|Pathfinder|{\"panel\": \"pathfinder\"}", "<", ">Output", From 5112019053c34f79de979c08fafcb516bb209f56 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 23:07:27 -0400 Subject: [PATCH 108/128] fix: integrate batched community PRs (quick-xml 0.41 from #687 in main's IDML attribute reader; #610's text-link reads pass #604's file guard; wasm-only unused re-export) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/datamerge/mod.rs | 1 + crates/engine/src/cmd/datamerge/parse.rs | 2 +- crates/engine/src/cmd/links.rs | 8 +++++--- crates/engine/src/io.rs | 2 +- crates/idml/src/import.rs | 7 ++++++- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index 73a6855b0..9015187eb 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -24,6 +24,7 @@ use parse::{ tile, }; +#[cfg(not(target_arch = "wasm32"))] pub(crate) use parse::fingerprint_from; pub(crate) fn fingerprint_from_path(path: &str) -> Option { diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index 08c0ddc4b..e7c9be6e3 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -334,7 +334,7 @@ pub fn fingerprint_of(path: &Path) -> Option { } /// Size and modification time (whole seconds) from a file's metadata. -pub fn fingerprint_from(meta: &std::fs::Metadata) -> Fingerprint { +pub fn fingerprint_from(meta: &crate::io::Metadata) -> Fingerprint { let mtime = meta .modified() .ok() diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 1a47cf619..9db6c6cb8 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -182,9 +182,11 @@ pub(crate) type TextChecks = std::collections::HashMap &'static str { #[cfg(not(target_arch = "wasm32"))] { + if !crate::io::is_file(&l.path) { + return "missing"; + } let now = match crate::io::metadata(&l.path) { Err(_) => return "missing", - Ok(m) if !m.is_file() => return "missing", Ok(m) => super::datamerge::fingerprint_from(&m), }; if now.size != l.size { @@ -266,7 +268,7 @@ fn read_text(path: &str) -> Result<(Vec, Fingerprint)> { // The roots' own message when they refuse the path. crate::file_access::check_read(path).map_err(EngineError::Other)?; let m = crate::io::metadata(path).map_err(err)?; - if !m.is_file() { + if !crate::io::is_file(path) { return Err(EngineError::Other(format!("{path}: not a file"))); } let too_large = || EngineError::Other(format!("{path}: larger than {} MiB", MAX_TEXT_LINK_BYTES / (1024 * 1024))); @@ -889,8 +891,8 @@ mod text_link_tests { } } -#[cfg(test)] #[cfg(unix)] +#[cfg(test)] mod text_link_read_tests { use std::time::Duration; diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 2c9a8520a..b27878a63 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -8,7 +8,7 @@ //! it is ([`is_file`], [`is_dir`], [`exists`], [`metadata`]), and as the roots' own message where //! it asks for the bytes. -use std::fs::Metadata; +pub use std::fs::Metadata; use std::path::Path; /// The bytes of the file at `path`, inside the automation read root when there is one. diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 9892559b2..69627e0ae 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -264,7 +264,11 @@ fn svg_viewbox_size(data: &[u8]) -> Option<(f64, f64)> { return None; } let attr = |name: &[u8]| { - root.attributes().with_checks(false).flatten().find(|a| a.key.as_ref() == name).and_then(|a| a.unescape_value().ok().map(|v| v.to_string())) + root.attributes() + .with_checks(false) + .flatten() + .find(|a| a.key.as_ref() == name) + .and_then(|a| a.normalized_value(quick_xml::XmlVersion::Implicit1_0).ok().map(|v| v.to_string())) }; let absolute = |name: &[u8]| attr(name).is_some_and(|v| !v.trim().ends_with('%')); if absolute(b"width") || absolute(b"height") { @@ -1718,6 +1722,7 @@ impl<'r> Importer<'r> { objects: objects.into_iter().map(Arc::new).collect(), editorial: Vec::new(), link: None, + file_link: None, }; st.fix_notes(); st.fix_marks(); From 0697b39a3aef53d7e99a8e8a74ebaef1dedf6a9c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 23:45:50 -0400 Subject: [PATCH 109/128] fix: integrate batched community PRs (rustfmt after the IDML/text cluster merges) Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 54126acf2..f2b8a9766 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -3228,4 +3228,3 @@ mod frame_preference_scalar_tests { assert_eq!(Prefs(&[&exact]).prop("ColumnRuleStrokeColor").as_deref(), Some(" Color/Named ")); } } - From 26137acbb251ac51c117f3e49e147dc84b937548 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 00:01:41 -0400 Subject: [PATCH 110/128] fix: integrate batched community PRs (#588's blend-space test uses as_chunks for clippy) Co-Authored-By: Claude Opus 5.5 --- crates/render/src/blendspace.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index dffde947c..4121831f7 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -643,7 +643,7 @@ mod tests { assert!(out.pixel(30, 40)[3].abs_diff(64) <= 1, "half-opacity object inside a half-opacity group is quarter opaque"); let cmy = renderer.render(&doc, &Cache::new(), &placements, 160, 120, Affine::IDENTITY, &RenderOptions { plate: Some(4), ..options.clone() }); let k = renderer.render(&doc, &Cache::new(), &placements, 160, 120, Affine::IDENTITY, &RenderOptions { plate: Some(3), ..options }); - for ((out, cmy), k) in out.pixels.chunks_exact(4).zip(cmy.pixels.chunks_exact(4)).zip(k.pixels.chunks_exact(4)) { + for ((out, cmy), k) in out.pixels.as_chunks::<4>().0.iter().zip(cmy.pixels.as_chunks::<4>().0).zip(k.pixels.as_chunks::<4>().0) { assert_eq!(cmy[3], k[3], "all ink passes have the same coverage"); assert_eq!(out[3], cmy[3], "conversion preserves coverage"); assert!(out[..3].iter().all(|v| *v <= out[3]), "output remains premultiplied"); From a930276fd7462f3d9abd2fbb38544c94c0c911c8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 00:35:27 -0400 Subject: [PATCH 111/128] fix: integrate batched community PRs (#170's PPTX exporter on the page-indexed compose cache and crate::io, with a Georgian menu label; #171: a deleted style's replacement is never based on itself) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 ++-- crates/engine/src/cmd/export.rs | 2 +- crates/engine/src/cmd/style.rs | 15 ++++++++++----- crates/engine/src/tests.rs | 17 +++++++++++++++++ crates/pptx/src/slide.rs | 9 +++++---- crates/pptx/src/text.rs | 2 +- crates/ui-egui/src/i18n/ka.rs | 1 + 7 files changed, 37 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40f895f54..6b735c5d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1112,7 +1112,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.5.0" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1121,7 +1121,7 @@ dependencies = [ "designcraft-geom", "designcraft-images", "image", - "quick-xml 0.38.4", + "quick-xml", "skrifa 0.47.0", "thiserror 2.0.21", "zip", diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 22cd2b44a..f10f3037d 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -102,7 +102,7 @@ fn export_pptx(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})) } None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index fc610ff33..1cccc7fd1 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -539,12 +539,16 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: & /// instead removes the entry, after its former uses point at the replacement). fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { let st = d.styles_mut(); + // A style is never based on itself: when `to` was based on `from`, it takes `from`'s parent. + let parent_of = |based_on: Option<&String>| based_on.filter(|b| b.as_str() != to).cloned(); if para { + let from_parent = parent_of(st.paragraph.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref())); for s in &mut st.paragraph { - for r in [&mut s.based_on, &mut s.next_style] { - if r.as_deref() == Some(from) { - *r = Some(to.to_string()); - } + if s.based_on.as_deref() == Some(from) { + s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) }; + } + if s.next_style.as_deref() == Some(from) { + s.next_style = Some(to.to_string()); } } for o in &mut st.object { @@ -553,9 +557,10 @@ fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str } } } else { + let from_parent = parent_of(st.character.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref())); for s in &mut st.character { if s.based_on.as_deref() == Some(from) { - s.based_on = Some(to.to_string()); + s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) }; } } for s in &mut st.paragraph { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1126b74c5..616a86398 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -701,6 +701,23 @@ fn paragraph_style_delete_rejects_missing_unknown_and_self_replacement() { assert!(s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": name})).is_err(), "replaceWith can't be the deleted style"); } +#[test] +fn deleting_a_style_never_leaves_its_replacement_based_on_itself() { + let mut s = session(); + let para = |s: &Session, n: &str| s.doc().unwrap().doc.styles.para(n).unwrap().based_on.clone(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Head"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Minor", "basedOn": "Sub"})).unwrap(); + // Minor is based on Sub; deleting Sub in favour of Minor gives Minor Sub's parent. + s.execute("style.paragraph.delete", &json!({"name": "Sub", "replaceWith": "Minor"})).unwrap(); + assert_eq!(para(&s, "Minor").as_deref(), Some("Head")); + s.execute("style.character.create", &json!({"name": "Strong"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Stronger", "basedOn": "Strong"})).unwrap(); + s.execute("style.character.delete", &json!({"name": "Strong", "replaceWith": "Stronger"})).unwrap(); + let based = s.doc().unwrap().doc.styles.character.iter().find(|c| c.name == "Stronger").unwrap().based_on.clone(); + assert_ne!(based.as_deref(), Some("Stronger")); +} + #[test] fn character_style_delete_reassigns_every_kind_of_reference() { let mut s = session(); diff --git a/crates/pptx/src/slide.rs b/crates/pptx/src/slide.rs index f4cf4386f..03fd0e8b9 100644 --- a/crates/pptx/src/slide.rs +++ b/crates/pptx/src/slide.rs @@ -208,7 +208,8 @@ pub struct Exporter<'a> { pub to_slide: Affine, /// The page's bleed area in spread space (items outside it are left out). clip: Rect, - pub page_name: Option, + /// The page parent items are composed for (page numbers, running heads). + pub page: Option, } impl<'a> Exporter<'a> { @@ -227,7 +228,7 @@ impl<'a> Exporter<'a> { rel_of: HashMap::new(), to_slide: Affine::IDENTITY, clip: Rect::ZERO, - page_name: None, + page: None, } } @@ -276,9 +277,9 @@ impl<'a> Exporter<'a> { continue; } // Parent items compose per page (page numbers, running heads). - self.page_name = Some(page_name.clone()); + self.page = Some(abs); self.item(it, Affine::translate((dx, 0.0)), 1.0); - self.page_name = None; + self.page = None; } } for it in &sp.items { diff --git a/crates/pptx/src/text.rs b/crates/pptx/src/text.rs index 966717716..b15cbe714 100644 --- a/crates/pptx/src/text.rs +++ b/crates/pptx/src/text.rs @@ -92,7 +92,7 @@ pub(crate) struct Segment { pub fn frame(ex: &mut Exporter, it: &Item, tfr: &TextFrame, xf: Affine, opacity: f32) { let doc = ex.doc; - let cs = ex.cache.get(doc, tfr.story, ex.page_name.as_deref()); + let cs = ex.cache.get(doc, tfr.story, ex.page); let Some(ft) = cs.frame(it.id) else { ex.shape(it, xf, opacity, None); return; diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 350ea526e..b3379c6d7 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1830,4 +1830,5 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert to Flex Layout", "მოქნილ განლაგებად გარდაქმნა"), ("Remove Flex Layout", "მოქნილი განლაგების წაშლა"), ("Show Flex Controls", "მოქნილი მართვის ელემენტების ჩვენება"), + ("Export PowerPoint…", "PowerPoint-ად ექსპორტი…"), ]; From 22f4049ff600b30a009252f6473743bcbfc8d865 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:12:53 +0000 Subject: [PATCH 112/128] fix(toolbar): Apply Gradient on the stroke is one undo step and keeps the fill's tint --- ROADMAP.md | 1 + crates/ui-egui/src/toolbar.rs | 38 ++++++++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..6574e0900 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Apply Gradient (.) with the Stroke box in front puts a new default gradient on the stroke in one undo step and leaves the fill's swatch and tint as they were. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 7e6ca1cd2..dfd8c4ce4 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -583,17 +583,10 @@ pub(crate) fn apply_gradient(app: &mut DesignApp) -> Result { if crate::panels::colors_affect_text(app) { return Err("no gradient to apply yet: make one in the Gradient panel".into()); } - let fill = crate::panels::color_target(app).map(|t| t.0); let stops = json!([{"location": 0, "color": "#ffffff"}, {"location": 100, "color": "#000000"}]); - let r = app.run("object.gradient", json!({"stops": stops}))?; - let sw = r["swatch"].as_str().unwrap_or_default().to_string(); - app.ui.last_gradient = Some(sw.clone()); - if stroke { - // The gradient was made on the fill: it goes to the stroke, the fill gets its swatch back. - crate::panels::apply_swatch(app, true, &sw, None)?; - if let Some(f) = fill { - app.run("object.fill", json!({"swatch": f}))?; - } + let r = app.run("object.gradient", json!({"stops": stops, "target": if stroke { "stroke" } else { "fill" }}))?; + if let Some(sw) = r["swatch"].as_str() { + app.ui.last_gradient = Some(sw.to_string()); } Ok(r) } @@ -767,6 +760,31 @@ mod tests { } } + /// A new default gradient on the stroke in front is one undo step; the fill keeps its swatch and tint. + #[test] + fn apply_gradient_to_the_stroke_is_one_undo_step() { + use serde_json::json; + let mut app = app(true); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200], "content": "unassigned"})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.fill", json!({"swatch": "[Paper]", "tint": 0.5})).unwrap(); + app.run("object.stroke", json!({"swatch": "[Black]", "tint": 0.7})).unwrap(); + let paints = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + let it = st.doc.item(st.selection.items[0]).unwrap(); + (it.fill.swatch.clone(), it.fill.tint, it.stroke.swatch.clone(), it.stroke.tint) + }; + let before = paints(&app); + app.ui.stroke_front = true; + app.run("color.applyGradient", json!({})).unwrap(); + let after = paints(&app); + assert_eq!((&after.0, after.1), (&before.0, before.1), "the fill is untouched"); + assert_ne!(after.2, before.2, "the stroke is the gradient"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(paints(&app), before, "one undo restores the stroke"); + } + /// The Fill/Stroke proxy's keys run on the selection and stay out of text being typed. #[test] fn fill_stroke_proxy_keys() { From f786a650e50e40c698cd7d215e1102a43053256a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:13:03 +0000 Subject: [PATCH 113/128] fix(type-menu): Define Lists command path matches the Bulleted & Numbered Lists menu --- ROADMAP.md | 1 + crates/doc/src/lib.rs | 2 +- crates/engine/src/cmd/lists.rs | 4 ++-- crates/ui-egui/src/menus.rs | 9 +++++++++ docs/target-app-parity.md | 2 +- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..67b669275 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Define Lists… is listed under Type › Bulleted & Numbered Lists in command search and Edit › Menus, matching the menu, and the parity doc no longer lists a Type › Paragraph Rules… menu item that does not exist. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c0fdc8fe2..8bf50071d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -324,7 +324,7 @@ pub struct DocSettings { pub bleed_color: [u8; 3], pub slug_color: [u8; 3], pub keyboard_increment: f64, - /// Named lists (Type › Bulleted and Numbered Lists › Define Lists). + /// Named lists (Type › Bulleted & Numbered Lists › Define Lists). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub lists: Vec, /// The story in the primary text frames (Smart Text Reflow adds and removes pages for it). diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index f6754562b..9f8824870 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -1,4 +1,4 @@ -//! Type › Bulleted and Numbered Lists › Define Lists: named lists whose numbers can continue from +//! Type › Bulleted & Numbered Lists › Define Lists: named lists whose numbers can continue from //! story to story. Paragraphs join one with `listName` (and restart with `startAt`). use designcraft_doc::NumberedList; @@ -11,7 +11,7 @@ pub fn specs() -> Vec { cmd!( "list.define", "Define Lists…", - ["Type", "Bulleted and Numbered Lists"], + ["Type", "Bulleted & Numbered Lists"], None, "{name, continueAcrossStories?: true, rename?} — paragraphs join it with type.para {listType: numbers, listName}", has_doc, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a35261d59..edab4329b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2565,6 +2565,15 @@ mod tests { out } + /// A command's menu path names the menu entries that really hold it. + #[test] + fn define_lists_command_path_matches_the_type_menu() { + let spec = designcraft_engine::command_specs().iter().find(|c| c.id == "list.define").expect("list.define is a command"); + assert_eq!(spec.menu[0], "Type"); + assert!(submenu_paths("Type").contains(&spec.menu.join("/")), "{:?} is not a Type submenu", spec.menu); + assert!(menu_ids("Type").contains(&"list.define")); + } + #[test] fn screen_mode_shows_the_current_mode_and_view_submenus_are_translated() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 62ba7f08a..972551151 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -278,7 +278,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room, or across all the columns of the frame with Span Footnotes Across Columns; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. -- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. +- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. From e1998538d1c75ef26e773ea815e182c11fc402f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:13:09 +0000 Subject: [PATCH 114/128] fix(layers): Enter in the rename field keeps an unnamed object's automatic label --- ROADMAP.md | 1 + crates/ui-egui/src/panels/layers.rs | 51 +++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..cec5a0c51 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Pressing Enter in the Layers panel's rename field without typing leaves an unnamed object unnamed, so it keeps following its automatic label instead of taking it as a fixed name. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 143be8117..aa857c5ed 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -240,7 +240,7 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { false } Some(None) => false, - None => ui.data(|d| d.get_temp::<(RenameKey, String)>(egui::Id::new(RENAME))).is_some_and(|(k, _)| k == it.id.0), + None => ui.data(|d| d.get_temp::(egui::Id::new(RENAME))).is_some_and(|(k, ..)| k == it.id.0), }; if !renaming { let clip = Rect::from_min_max(row.min, pos2(row.max.x - 26.0, row.max.y)); @@ -293,14 +293,16 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { /// The object whose row is being renamed. type RenameKey = u64; +/// The row, the field's text, and the text it started with. +type RenameState = (RenameKey, String, String); const RENAME: &str = "layers_rename"; /// The name field of the row being renamed (double-click), in `r`: `Some(Some(name))` when Enter -/// or a click elsewhere confirms it, `Some(None)` when Esc cancels, `None` while typing or when -/// this row isn't being renamed. +/// or a click elsewhere confirms it with a changed text, `Some(None)` when Esc cancels or the text +/// is the one it started with, `None` while typing or when this row isn't being renamed. fn rename_field(ui: &mut Ui, r: Rect, key: RenameKey) -> Option> { let id = egui::Id::new(RENAME); - let (k, mut text): (RenameKey, String) = ui.data(|d| d.get_temp(id))?; + let (k, mut text, start): RenameState = ui.data(|d| d.get_temp(id))?; if k != key { return None; } @@ -311,15 +313,18 @@ fn rename_field(ui: &mut Ui, r: Rect, key: RenameKey) -> Option> let done = if ui.input(|i| i.key_pressed(egui::Key::Escape)) { Some(None) } else if edit.lost_focus() { - Some(Some(text.trim().to_string())) + // An unnamed object's field starts with its automatic label; confirming that text + // unchanged must not turn the label into a name. + let new = text.trim(); + if new == start.trim() { Some(None) } else { Some(Some(new.to_string())) } } else { None }; ui.data_mut(|d| { if done.is_some() { - d.remove::<(RenameKey, String)>(id); + d.remove::(id); } else { - d.insert_temp(id, (key, text)); + d.insert_temp(id, (key, text, start)); } }); done @@ -327,7 +332,7 @@ fn rename_field(ui: &mut Ui, r: Rect, key: RenameKey) -> Option> /// Start renaming row `key`, its field showing `name`. fn start_rename(ui: &Ui, key: RenameKey, name: &str) { - ui.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, name.to_string()))); + ui.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, name.to_string(), name.to_string()))); } #[cfg(test)] @@ -381,7 +386,7 @@ mod tests { h.run_steps(2); let rename = |h: &mut Harness<'_, DesignApp>, key: RenameKey, typed: &str| { // As a double-click starts it (with an emptied field here, so the typing is the name). - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, String::new()))); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, String::new(), String::new()))); h.run_steps(2); h.get_by_role(egui::accesskit::Role::TextInput).type_text(typed); h.run_steps(1); @@ -391,11 +396,37 @@ mod tests { rename(&mut h, id, "Hero bottle"); assert_eq!(h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().name, "Hero bottle"); // Esc leaves the name as it was. - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (id, String::new()))); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (id, String::new(), String::new()))); h.run_steps(2); h.get_by_role(egui::accesskit::Role::TextInput).type_text("Oops"); h.key_press(egui::Key::Escape); h.run_steps(2); assert_eq!(h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().name, "Hero bottle"); } + + #[test] + fn confirming_an_unedited_automatic_label_keeps_the_object_unnamed() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(260.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); + h.run_steps(2); + let name = |h: &Harness<'_, DesignApp>| h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().name.clone(); + // As a double-click on the unnamed row starts it: the field shows the automatic label. + let start = |h: &mut Harness<'_, DesignApp>| { + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (id, "Rectangle".to_string(), "Rectangle".to_string()))); + h.run_steps(2); + }; + start(&mut h); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(name(&h), "", "Enter without editing keeps following the automatic label"); + start(&mut h); + h.get_by_role(egui::accesskit::Role::TextInput).type_text("X"); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!(name(&h).contains('X'), "an edited text becomes the name"); + } } From 27025e5245b0d571149ccacf8552677f28c2f761 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:13:26 +0000 Subject: [PATCH 115/128] fix(paths): Join Path and Convert Point leave paths on locked layers and in locked groups unchanged --- ROADMAP.md | 1 + crates/engine/src/cmd/anchors.rs | 21 +++++++++++++++++---- crates/engine/src/cmd/object.rs | 2 ++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..86063f778 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Join Path and Convert Point leave a path on a locked layer or inside a locked group unchanged, as the other path edits do. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index c6e285200..f2225c480 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -82,8 +82,13 @@ pub fn specs() -> Vec { fn join_paths(s: &mut Session, p: &Value) -> Result { let ids = super::targets(s, p)?; s.edit(|d, sel| { - let ids: Vec = - ids.iter().copied().filter(|id| d.item(*id).is_some_and(|it| !it.locked && !it.is_group() && it.text_frame().is_none())).collect(); + let paths: Vec = ids.iter().copied().filter(|id| d.item(*id).is_some_and(|it| !it.is_group() && it.text_frame().is_none())).collect(); + let ids: Vec = paths.iter().copied().filter(|id| path_editable(d, *id, "path.join").is_ok()).collect(); + if ids.is_empty() + && let Some(id) = paths.first() + { + path_editable(d, *id, "path.join")?; + } let open = |d: &Document, id: ItemId| d.item(id).and_then(|it| it.path.subpaths.iter().position(|sp| !sp.closed && sp.anchors.len() > 1)); match ids.as_slice() { [id] => { @@ -162,11 +167,16 @@ fn convert_points(s: &mut Session, p: &Value) -> Result { } s.edit(|d, _| { let mut converted = 0; + let mut refused = None; for (id, si, ai) in &points { - let Some(it) = d.item_mut(*id) else { continue }; - if it.locked || it.is_group() { + if d.item(*id).is_none_or(|it| it.is_group()) { continue; } + if let Err(e) = path_editable(d, *id, "path.convertPoints") { + refused.get_or_insert(e); + continue; + } + let Some(it) = d.item_mut(*id) else { continue }; let Some(sp) = it.path.subpaths.get_mut(*si) else { continue }; let Some(a) = sp.anchors.get(*ai).copied() else { continue }; let auto = smooth_auto(&sp.anchors, *ai, sp.closed); @@ -194,6 +204,9 @@ fn convert_points(s: &mut Session, p: &Value) -> Result { } as_path(&mut it.shape); } + if let (0, Some(e)) = (converted, refused) { + return Err(e); + } Ok(json!({"converted": converted})) }) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index bbc0a36a1..a05024388 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3404,6 +3404,8 @@ mod path_edit_tests { ("path.erase", json!({"id": id, "points": [[120, 100], [180, 100]]})), ("path.smooth", json!({"id": id, "points": [[120, 100], [180, 100]]})), ("path.split", json!({"id": id, "at": [150, 100]})), + ("path.join", json!({"ids": [id]})), + ("path.convertPoints", json!({"ids": [id], "kind": "corner"})), ] } From 22de618def6bab90c3df9999441a00e45eb73b01 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:13:38 +0000 Subject: [PATCH 116/128] fix(doc): consecutive right pages after a section restart get separate spreads --- ROADMAP.md | 1 + crates/doc/src/build.rs | 5 ++++- crates/doc/src/tests.rs | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..d1c2872e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · In a facing-pages document, two odd-numbered pages in a row after a section restart (page 2 starting at 1) each get their own spread and stay right pages, instead of sharing a spread with an odd page on the left. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 9fb075553..aa6cebac7 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -346,7 +346,10 @@ impl Document { let odd = parity.get(i).copied().unwrap_or(i % 2 == 0); p.side = if odd != rtl { PageSide::Right } else { PageSide::Left }; let starts = if rtl { PageSide::Right } else { PageSide::Left }; - if p.side == starts && !cur.is_empty() { + // A right page closes its spread, so two right pages in a row (a section restart + // at an even position) never share one. + let after_ending = cur.last().is_some_and(|(q, _)| q.side != starts); + if (p.side == starts || after_ending) && !cur.is_empty() { let id = SpreadId(self.alloc()); flush(&mut cur, &mut spreads, id); } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 8af281d3b..a602cca25 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -262,6 +262,29 @@ fn page_names_follow_sections() { assert_eq!(d.page_name(5), "4"); } +/// A section restarting at 1 on page 2 leaves two odd (right) pages in a row; each gets its own +/// spread instead of sharing one with the odd page on the left. +#[test] +fn consecutive_right_pages_get_separate_spreads() { + let mut d = Document::new(&NewDocument { pages: 4, ..Default::default() }); + d.sections.push(Section { + start: 1, + start_number: Some(1), + style: NumberStyle::Arabic, + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }); + d.repaginate(); + let sides: Vec> = d.spreads.iter().map(|s| s.pages.iter().map(|p| p.side).collect()).collect(); + assert_eq!(sides, vec![vec![PageSide::Right], vec![PageSide::Right], vec![PageSide::Left, PageSide::Right]]); + let flat: Vec = sides.into_iter().flatten().collect(); + for (i, side) in flat.iter().enumerate() { + assert_eq!(*side == PageSide::Right, d.page_number(i) % 2 == 1, "page {i}"); + } + d.check().unwrap(); +} + #[test] fn document_serde_roundtrip() { let mut d = doc(); From 19a1085c96f002bce25130f76401ca82e0fc7ed3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:13:39 +0000 Subject: [PATCH 117/128] fix(type): formatting set on a blank line applies to the text typed there --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 40 +++++++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..9dc6ececb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Character formatting set at a caret on a blank line (an empty paragraph) applies to the text then typed there as well as to the line's paragraph mark; typed text used to take the previous paragraph's format. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 432297f35..a189c9620 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1100,19 +1100,26 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { validate_char_fill(&s.doc()?.doc, &a, "type.char")?; let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); let targets = format_targets(s); - // A caret in text: the format is for the next typing — unless it sits on an empty paragraph's - // hidden mark, which takes the format immediately (there is nothing else on that line to type - // into first). + // A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it + // also formats the mark right away, since the mark alone sets that line's height; typing there + // still needs the typing format, as new text takes the format of the run before the caret + // (the previous paragraph's mark). if let [t] = targets.as_slice() && t.range.is_empty() && let Some(at) = s.doc()?.selection.text - && s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty() && !empty_para_mark(st, t.range.start)) + && let Some(on_mark) = s.doc()?.doc.text_story(t.story, t.cell).filter(|st| !st.is_empty()).map(|st| empty_para_mark(st, t.range.start)) { let doc = s.doc()?.uid; let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] }); f.attrs.merge(&a); f.cleared.retain(|k| attrs.get(k).is_none_or(|v| v.is_null()) && !cleared.contains(k)); - f.cleared.extend(cleared); + f.cleared.extend(cleared.iter().cloned()); + if on_mark { + s.edit(|d, _| { + apply_char_attrs(d, &targets, &a, &cleared); + ok() + })?; + } s.typing_format = Some(f); return ok(); } @@ -1859,13 +1866,32 @@ What's new in typography this season, and why it matters to every designer"}), // `char_format_at` resolves a boundary to the *preceding* run (new typing's format), so // check just past the mark's own one-byte run, which the mark alone occupies. assert_eq!(st.char_format_at(blank + 1).over.leading, Some(designcraft_doc::Leading::Points(40.0))); - // No pending typing format was left behind: it applied immediately. - assert!(s.typing_format.is_none()); let d = s.doc().unwrap().doc.clone(); let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); let blank_line = cs.frames[0].lines.iter().find(|l| l.range == (blank..blank)).unwrap(); assert_eq!(blank_line.leading, 40.0, "the blank line's own height changed"); } + + /// Formatting chosen on a blank line is also what gets typed there, not the format of the + /// previous paragraph's mark that new text would otherwise take. + #[test] + fn text_typed_on_a_blank_line_takes_the_format_set_there() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Para one\n\nPara two"; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let blank = text.find("\n\n").unwrap() + 1; + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("text.insert", &json!({"text": "Middle"})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap(); + assert_eq!(st.text, "Para one\nMiddle\nPara two"); + for b in blank..blank + "Middle\n".len() { + assert_eq!(st.format_after(b).over.size, Some(30.0), "byte {b}"); + } + assert_eq!(st.format_after(blank - 1).over.size, None, "the first paragraph's mark keeps its size"); + } } #[cfg(test)] From 34a87ff42de5b96aeb9a8bde9ec6b1bca4562f84 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:15:08 +0000 Subject: [PATCH 118/128] fix(type): kerning at a caret kerns the pair at the caret --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 99 +++++++++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 31 ++++++-- 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..31bdd97f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · A kerning value chosen in the Character panel or Properties at a caret kerns the pair at the caret (the character before it takes the value, in one undo step), and the field shows that pair's kerning; at the start of a story or paragraph it is kept for the next typing, and a selection is kerned as before (`type.kernPair`). No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 432297f35..64142cdd9 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -155,6 +155,15 @@ pub fn specs() -> Vec { has_text_or_frames, |s, p| format_chars(s, p.get("attrs").unwrap_or(p)) ), + cmd!( + "type.kernPair", + "Kerning", + [], + None, + "{kerning: {kind: metrics|optical|none}|{kind: manual, value: 1/1000 em}|null} — at a caret in text, the pair at the caret (the character before the caret takes it); at the start of a story or paragraph, the next typing; a selection or frames take it like `type.char`", + has_text_or_frames, + kern_pair + ), cmd!( "type.para", "Paragraph Formatting", @@ -1122,6 +1131,44 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { }) } +/// Kerning spaces a character from the next one, so the pair at a caret is kerned through the +/// character before it. With no such pair (story or paragraph start, a selection) this is +/// `type.char`. +fn kern_pair(s: &mut Session, p: &Value) -> Result { + const ID: &str = "type.kernPair"; + let kerning = p.get("kerning").ok_or_else(|| bad(ID, "missing `kerning`"))?; + let targets = format_targets(s); + if let [t] = targets.as_slice() + && t.range.is_empty() + && s.doc()?.selection.text.is_some() + && let Some(start) = s.doc()?.doc.text_story(t.story, t.cell).and_then(|st| pair_start(&st.text, t.range.start)) + { + let mut a = CharAttrs::default(); + a.set_json("kerning", kerning).map_err(|e| bad(ID, e))?; + let cleared = if kerning.is_null() { vec!["kerning".to_string()] } else { vec![] }; + let pair = Target { story: t.story, cell: t.cell, range: start..t.range.start }; + // Kerning chosen earlier for typing at this caret would hide the pair's value. + if typing_format(s).is_some() + && let Some(f) = &mut s.typing_format + { + f.attrs.kerning = None; + f.cleared.retain(|k| k != "kerning"); + } + return s.edit(|d, _| { + apply_char_attrs(d, &[pair], &a, &cleared); + ok() + }); + } + format_chars(s, &json!({ "kerning": kerning })) +} + +/// Byte offset of the character before `pos` when it starts a kerning pair with the text at `pos` +/// (not a paragraph break). +fn pair_start(text: &str, pos: usize) -> Option { + let c = text.get(..pos)?.chars().next_back().filter(|c| *c != '\n')?; + pos.checked_sub(c.len_utf8()) +} + /// Character attributes `a` (and the overrides `cleared` removed) over each target's text. pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) { for t in targets { @@ -3045,3 +3092,55 @@ mod placeholder_tests { assert!(text.len() > 1500, "the frame is filled: {}", text.len()); } } + +#[cfg(test)] +mod kern_pair_tests { + use designcraft_doc::{Kerning, StoryId}; + use serde_json::json; + + use crate::Session; + + fn avatar() -> (Session, StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "AVATAR"})).unwrap(); + (s, StoryId(r["story"].as_u64().unwrap())) + } + + fn kern_at(s: &Session, story: StoryId, pos: usize) -> Option { + s.doc().unwrap().doc.story(story).unwrap().format_after(pos).over.kerning + } + + #[test] + fn kerning_at_a_caret_kerns_the_pair_in_one_undo_step() { + let (mut s, sid) = avatar(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": -80}})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), Some(Kerning::Manual(-80.0)), "A, before the caret, spaces the A|V pair"); + assert_eq!(kern_at(&s, sid, 1), None); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let shown = s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["kerning"].clone(); + assert_eq!(shown, json!({"kind": "manual", "value": -80.0}), "the caret shows the pair's kerning"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), None); + } + + #[test] + fn kerning_a_selection_or_at_the_story_start_works_like_type_char() { + let (mut s, sid) = avatar(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 2, "focus": 4})).unwrap(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": 20}})).unwrap(); + let kerned: Vec<_> = (0..6).map(|i| kern_at(&s, sid, i)).collect(); + let m = Some(Kerning::Manual(20.0)); + assert_eq!(kerned, [None, None, m, m, None, None]); + // No character before the caret: the value is for the next typing. + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "none"}})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + assert_eq!(kern_at(&s, sid, 0), None); + s.execute("text.insert", &json!({"text": "X"})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), Some(Kerning::None)); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 52ce7e688..49db7c131 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1283,8 +1283,9 @@ fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { } } -/// Kerning for the text selection: the label shows Metrics, Optical, 0 or the manual value; its -/// menu sets one of the first three or a manual value in 1/1000 em (typed or stepped). +/// Kerning for the text selection, or at a caret for the pair at the caret: the label shows +/// Metrics, Optical, 0 or the manual value; its menu sets one of the first three or a manual value +/// in 1/1000 em (typed or stepped). fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) { let kind = k["kind"].as_str().unwrap_or("metrics"); let manual = k["value"].as_f64().filter(|v| kind == "manual" && v.is_finite()); @@ -1300,16 +1301,16 @@ fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { for (text, v) in [("Metrics", json!({"kind": "metrics"})), ("Optical", json!({"kind": "optical"})), ("0", json!({"kind": "none"}))] { if ui.selectable_label(label == text, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, text))).clicked() { - let _ = app.run("type.char", json!({"attrs": {"kerning": v}})); + let _ = app.run("type.kernPair", json!({"kerning": v})); ui.close(); } } ui.separator(); - // A manual value: between the selected character and the next, in 1/1000 em. + // A manual value in 1/1000 em: after each selected character, or between the two at a caret. let field = format!("{id}_manual"); if let Some(v) = NumField::number(&field, Some(manual.unwrap_or(0.0)), "", 0).width(90.0).spinner().step(5.0).range(-1000.0, 10000.0).show(ui) { - let _ = app.run("type.char", json!({"attrs": {"kerning": {"kind": "manual", "value": v}}})); + let _ = app.run("type.kernPair", json!({"kerning": {"kind": "manual", "value": v}})); } }); } @@ -2668,6 +2669,26 @@ mod tests { assert_eq!(kerning(h.state_mut()), json!({"kind": "manual", "value": -40.0})); } + #[test] + fn a_manual_kerning_value_at_a_caret_kerns_the_pair() { + let mut app = text_app(); + let story = app.session.active().unwrap().selection.text.unwrap().story; + app.run("text.select", json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); + let mut h = panel_harness(app, character_panel); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Kerning").click(); + h.run_steps(2); + let field = h.get_all_by_role(egui::accesskit::Role::TextInput).last().unwrap(); + field.focus(); + field.type_text("-80"); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let st = h.state_mut().session.active().unwrap().doc.story(story).unwrap().format_after(0).over.kerning; + assert_eq!(st, Some(designcraft_doc::Kerning::Manual(-80.0)), "A of the A|V pair"); + assert_eq!(kerning(h.state_mut()), json!({"kind": "manual", "value": -80.0})); + } + #[test] fn clicking_a_swatch_chip_in_the_appearance_fill_menu_applies_it() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 6b43209e3076f3eeae025e5e1d91b119738c0e58 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:15:15 +0000 Subject: [PATCH 119/128] fix(type): text typed on a blank line takes the line's paragraph mark format --- ROADMAP.md | 2 +- crates/doc/src/story.rs | 15 ++++++++++++--- crates/engine/src/cmd/text.rs | 28 ++++++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9dc6ececb..9ca5c3010 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,7 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-15** · Character formatting set at a caret on a blank line (an empty paragraph) applies to the text then typed there as well as to the line's paragraph mark; typed text used to take the previous paragraph's format. No readiness percentage change. +- **2026-10-15** · Character formatting set at a caret on a blank line (an empty paragraph) applies to the line's paragraph mark and to the text typed there, also after the caret has left the line and come back; typed text used to take the previous paragraph's format. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 68712b6f7..b68072c10 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -272,9 +272,18 @@ impl Story { }) } - /// Insert `text` at byte `pos` using the format at that position. + /// The format text inserted at byte `pos` takes: the run before `pos` + /// ([`Story::char_format_at`]), except on an empty paragraph, where it is that paragraph's own + /// mark (the line's only character, so its format is the one chosen for that line). + pub fn insert_format_at(&self, pos: usize) -> &CharFormat { + let b = self.text.as_bytes(); + let empty_para = b.get(pos) == Some(&b'\n') && pos.checked_sub(1).and_then(|p| b.get(p)) == Some(&b'\n'); + if empty_para { self.format_after(pos) } else { self.char_format_at(pos) } + } + + /// Insert `text` at byte `pos` using the format at that position ([`Story::insert_format_at`]). pub fn insert(&mut self, pos: usize, text: &str) { - let fmt = self.char_format_at(pos).clone(); + let fmt = self.insert_format_at(pos).clone(); self.insert_with(pos, text, fmt); } @@ -392,7 +401,7 @@ impl Story { /// Replace a range with text (delete + insert using the format at the start of the range). pub fn replace(&mut self, range: std::ops::Range, text: &str) { - let fmt = if range.start < range.end { self.format_after(range.start).clone() } else { self.char_format_at(range.start).clone() }; + let fmt = if range.start < range.end { self.format_after(range.start).clone() } else { self.insert_format_at(range.start).clone() }; self.delete(range.clone()); self.insert_with(range.start, text, fmt); } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a189c9620..66d06b57d 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -614,7 +614,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { if tracking && t.cell.is_none() { // Track Changes: the replaced text is marked deleted, the typing inserted after it. let at = super::changes::mark_deleted(st, r.clone()); - let mut fmt = st.char_format_at(at).clone(); + let mut fmt = st.insert_format_at(at).clone(); if let Some(f) = &typing { apply_typing_format(&mut fmt, f); } @@ -1295,7 +1295,8 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { /// inheritance separate from paragraph defaults for style creation from the selection. pub(crate) fn effective_char_format(s: &Session, target: &Target) -> Result { let story = s.doc()?.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; - let mut format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone(); + let mut format = + if target.range.is_empty() { story.insert_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone(); if let Some(pending) = typing_format(s) && target.story == pending.at.story && target.cell == pending.at.cell @@ -1892,6 +1893,29 @@ What's new in typography this season, and why it matters to every designer"}), } assert_eq!(st.format_after(blank - 1).over.size, None, "the first paragraph's mark keeps its size"); } + + /// A blank line keeps its format for typing after the caret has left it: text typed on it + /// takes its paragraph mark's format, with no pending typing format. + #[test] + fn a_blank_lines_mark_format_is_typed_after_the_caret_returns() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Para one\n\nPara two"; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let blank = text.find("\n\n").unwrap() + 1; + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 2})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + assert!(s.typing_format.is_none()); + s.execute("text.insert", &json!({"text": "Middle"})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap(); + assert_eq!(st.text, "Para one\nMiddle\nPara two"); + assert_eq!(st.format_after(blank).over.size, Some(30.0)); + assert_eq!(st.format_after(blank - 1).over.size, None, "the first paragraph's mark keeps its size"); + assert_eq!(st.format_after(2).over.size, None); + } } #[cfg(test)] From 21f14d7f79a30a581fc03068fad446982d91f18e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:15:21 +0000 Subject: [PATCH 120/128] fix(render): wide feathers and inner effects stay sharp at high zoom --- ROADMAP.md | 1 + crates/render/src/fx.rs | 75 ++++++++++++++++++++++------------ crates/render/tests/effects.rs | 61 +++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..d4403a13b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Very wide effects stay sharp at high zoom: a wide Basic Feather draws the object itself at full resolution, with only the soft mask at a reduced one, and wide inner shadows, inner glows, satin and bevels are clipped to the shape at full resolution, so they no longer blur the object or bleed past its edge. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 264d641db..527b09c00 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -4,7 +4,8 @@ //! vello_cpu only supports filter layers in single-threaded contexts, so on the multithreaded //! pipeline each effect is rendered into an offscreen single-threaded context cropped to its //! reach and composited back as an image ([`Renderer::with_filters`]); single-threaded contexts -//! draw directly. A very wide blur goes offscreen in both, at a lower resolution. +//! draw directly. A very wide blur goes offscreen in both, at a lower resolution; the object +//! itself and the clip to its shape stay at full resolution. //! //! - Drop shadow / outer glow: the object's silhouette (everything it paints: fill, image, text, //! stroke) in a `DropShadowOnly` layer under the object. `size` sets the blur (σ = size / 2), @@ -113,7 +114,7 @@ impl Renderer { // The offset moves the geometry, not the filter: vello drops layer content that lies // entirely outside the viewport before filtering. let r = (reach + off).inflate(grow, grow); - self.with_filters(ctx, f, r, sigma, |me, c, fr| { + self.with_filters(ctx, f, r, sigma, None, |me, c, fr| { let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; c.set_transform(moved.view); c.push_layer(None, None, None, None, Some(filter)); @@ -138,13 +139,15 @@ impl Renderer { // The object, feathered or not. if e.feather > 0.0 && it.path.is_closed() { let w = e.feather; - self.with_filters(ctx, f, reach, w / 4.0, |me, c, fr| { - c.set_transform(fr.view * xf); - c.push_clip_layer(bp); - me.draw_body(c, fr, it, bp, xf, page); - // Keep the body where the blurred inset silhouette is. - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + // The body and its clip stay at device resolution; only the blurred mask may be drawn + // offscreen at a lower one. + ctx.set_transform(f.view * xf); + ctx.push_clip_layer(bp); + self.draw_body(ctx, f, it, bp, xf, page); + // Keep the body where the blurred inset silhouette is. + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + self.with_filters(ctx, f, reach, w / 4.0, None, |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(w / 4.0))); c.set_transform(fr.view * xf); @@ -157,9 +160,9 @@ impl Renderer { c.stroke_path(bp); c.pop_layer(); c.pop_layer(); - c.pop_layer(); - c.pop_layer(); }); + ctx.pop_layer(); + ctx.pop_layer(); } else { self.draw_body(ctx, f, it, bp, xf, page); } @@ -217,9 +220,7 @@ impl Renderer { let (sigma, choke) = split(g.size, g.choke); let paint = color_of(&c, g.opacity.clamp(0.0, 1.0)); if g.center { - self.with_blended_filters(ctx, f, reach, sigma, Mix::Screen, |_, c, fr| { - c.set_transform(fr.view * xf); - c.push_clip_layer(bp); + self.with_blended_filters(ctx, f, reach, sigma, Mix::Screen, (bp, xf), |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); c.set_transform(fr.view * xf); @@ -234,7 +235,6 @@ impl Renderer { c.stroke_path(bp); c.pop_layer(); c.pop_layer(); - c.pop_layer(); }); } else { self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, Mix::Screen); @@ -249,9 +249,7 @@ impl Renderer { let sigma = st.size.max(0.0) / 2.0; let paint = color_of(&c, st.opacity.clamp(0.0, 1.0)); let invert = st.invert; - self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, |_, c, fr| { - c.set_transform(fr.view * xf); - c.push_clip_layer(bp); + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); if invert { @@ -271,7 +269,6 @@ impl Renderer { c.pop_layer(); c.pop_layer(); c.pop_layer(); - c.pop_layer(); }); } // Bevel and Emboss (inner bevel): highlight from the lit side, shadow from the other. @@ -305,9 +302,7 @@ impl Renderer { paint: peniko::Color, mix: Mix, ) { - self.with_blended_filters(ctx, f, reach, sigma, mix, |_, c, fr| { - c.set_transform(fr.view * xf); - c.push_clip_layer(bp); + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); // Everything outside the offset shape, so the colour bleeds in from the edges. @@ -326,12 +321,13 @@ impl Renderer { c.stroke_path(&shape); } c.pop_layer(); - c.pop_layer(); }); } - /// The effect's blend must see the object's backdrop, outside its clipping and offscreen + /// [`Renderer::with_filters`] clipped to `clip` (a path and its spread transform), blended with + /// `mix`. The effect's blend must see the object's backdrop, outside its clipping and offscreen /// filter layers. Putting it inside the clip blends with transparent pixels instead. + #[allow(clippy::too_many_arguments)] fn with_blended_filters( &mut self, ctx: &mut RenderContext, @@ -339,13 +335,14 @@ impl Renderer { reach: Rect, sigma: f64, mix: Mix, + clip: (&BezPath, Affine), draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), ) { if mix != Mix::Normal { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); } - self.with_filters(ctx, f, reach, sigma, draw); + self.with_filters(ctx, f, reach, sigma, Some(clip), draw); if mix != Mix::Normal { ctx.pop_layer(); } @@ -354,35 +351,59 @@ impl Renderer { /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a /// multithreaded context, or for a blur wider than [`MAX_BLUR_SPREAD`], the layer is drawn into /// an offscreen single-threaded context covering `reach` (spread space) plus the blur's spread, - /// then composited back. + /// then composited back. `clip` (a path and its spread transform) clips what `draw` paints; + /// at a lower resolution it clips the composited image on `ctx`, so its edge stays sharp. pub(crate) fn with_filters( &mut self, ctx: &mut RenderContext, f: &Frame, reach: Rect, sigma: f64, + clip: Option<(&BezPath, Affine)>, draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), ) { // 3σ of the Gaussian in pixels, plus a pixel of antialiasing. let spread = sigma.max(0.0) * 3.0 / f.px + 2.0; if !f.mt && spread <= MAX_BLUR_SPREAD { - return draw(self, ctx, f); + push_clip(ctx, f, clip); + draw(self, ctx, f); + pop_clip(ctx, clip); + return; } let Some(o) = offscreen(f.view.transform_rect_bbox(reach), spread, ctx.width(), ctx.height()) else { return }; let mut off = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -o.y0)) * f.view; let shifted = Frame { mt: false, view, px: f.px * o.scale, ..*f }; + // Clip where the result is at full resolution: offscreen unless that is reduced. + let (inner, outer) = if o.scale > 1.0 { (None, clip) } else { (clip, None) }; + push_clip(&mut off, &shifted, inner); draw(self, &mut off, &shifted); + pop_clip(&mut off, inner); off.flush(); let mut pm = vello_cpu::Pixmap::new(o.width, o.height); off.render(&mut pm, &mut self.resources); + push_clip(ctx, f, outer); ctx.set_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale)); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(pm)), sampler: peniko::ImageSampler::default() }); ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(o.width), f64::from(o.height))); + pop_clip(ctx, outer); ctx.set_transform(Affine::IDENTITY); } } +fn push_clip(ctx: &mut RenderContext, f: &Frame, clip: Option<(&BezPath, Affine)>) { + if let Some((bp, xf)) = clip { + ctx.set_transform(f.view * xf); + ctx.push_clip_layer(bp); + } +} + +fn pop_clip(ctx: &mut RenderContext, clip: Option<(&BezPath, Affine)>) { + if clip.is_some() { + ctx.pop_layer(); + } +} + /// The widest blur (3σ, device pixels) drawn at full resolution. vello pads a filter layer by the /// blur's width on every side, so a wide blur needs gigabytes; it is drawn at a lower resolution /// instead, where it is this wide, and has no detail to lose there. diff --git a/crates/render/tests/effects.rs b/crates/render/tests/effects.rs index bd0160ee1..65fb2bbe5 100644 --- a/crates/render/tests/effects.rs +++ b/crates/render/tests/effects.rs @@ -199,6 +199,67 @@ fn shadow_of_an_object_outside_the_view_still_reaches_it() { assert!(lum(img.pixel(8, 80)) < 200, "{:?}", img.pixel(8, 80)); } +/// Points 70..90 of the page at 8 px a point (400% on a 2x screen), where a wide blur is drawn +/// offscreen at a lower resolution. +fn render_zoomed(d: &Document) -> Rendered { + let mut r = Renderer::new(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let view = Affine::scale(8.0) * Affine::translate(Vec2::new(-70.0, -70.0)); + r.render(d, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), xf: Affine::translate(Vec2::ZERO) }], SIZE, SIZE, view, &opts) +} + +#[test] +fn a_wide_feather_keeps_the_object_sharp_at_high_zoom() { + let mut d = doc(); + // A huge yellow frame holding a small black square (pt 78..82, px 64..96 when zoomed). + let id = rect_item(&mut d, Rect::new(-5000.0, -5000.0, 5160.0, 5160.0), "C=0 M=0 Y=100 K=0"); + let kid = Item { + fill: Fill::swatch("[Black]"), + ..Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(78.0, 78.0, 82.0, 82.0))) + }; + d.item_mut(id).unwrap().content = designcraft_doc::Content::Group { items: vec![std::sync::Arc::new(kid)] }; + let plain = render_zoomed(&d); + // 3σ of a 1,000 pt feather is 6,000 px here, so its mask is drawn at about 1/6 resolution. + d.item_mut(id).unwrap().effects.feather = 1000.0; + let img = render_zoomed(&d); + let worst = plain.pixels.iter().zip(&img.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(worst <= 4, "the inside of a feathered object matches the object: max channel difference {worst}"); +} + +#[test] +fn a_wide_inner_effect_stays_inside_the_shape_at_high_zoom() { + let mut d = doc(); + // A black backdrop shows a screened glow; white paper shows a shadow. + let backdrop = rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "[Black]"); + // The shape's right edge is at pt 80, px 80 when zoomed. + let id = rect_item(&mut d, Rect::new(-2000.0, -2000.0, 80.0, 2160.0), "C=100 M=0 Y=0 K=0"); + for glow in [false, true] { + d.item_mut(backdrop).unwrap().fill = Fill::swatch(if glow { "[Black]" } else { "[None]" }); + let plain = render_zoomed(&d); + let e = &mut d.item_mut(id).unwrap().effects; + // 3σ is 24,000 px, so the effect is drawn at about 1/23 resolution. + if glow { + e.inner_glow.on = true; + e.inner_glow.size = 2000.0; + e.inner_glow.opacity = 1.0; + } else { + e.inner_shadow.on = true; + e.inner_shadow.color = "[Black]".into(); + e.inner_shadow.distance = 0.0; + e.inner_shadow.size = 2000.0; + e.inner_shadow.opacity = 1.0; + } + let img = render_zoomed(&d); + assert_ne!(lum(img.pixel(78, 80)), lum(plain.pixel(78, 80)), "glow {glow}: the effect shows inside the edge"); + for x in 81..110 { + assert_eq!(lum(img.pixel(x, 80)), lum(plain.pixel(x, 80)), "glow {glow}: nothing outside the edge at x {x}"); + } + let e = &mut d.item_mut(id).unwrap().effects; + e.inner_glow.on = false; + e.inner_shadow.on = false; + } +} + /// Multithreaded contexts draw effects offscreen; the result must match single-threaded. #[test] fn multithreaded_effects_match_single_threaded() { From eec4ad3e658cf2739fd61f24f38d6eb1de1a5022 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:16:15 +0000 Subject: [PATCH 121/128] fix(typography): every OpenType toggle has a control, single figure choices export, Standard Ligatures sets Ligatures The Typography panel's Other Features section lists the otf::TOGGLES tags no other section shows. Choosing a figure style or width alone also sets the other axis (the one all of the selection has, else Proportional or Lining), so IDML export writes a figure style instead of Default; turning one off clears both axes. Standard Ligatures reads and sets the ligatures attribute through type.char, and type.openTypeFeatures reports liga from it. --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 23 ++- crates/ui-egui/src/panels/typography.rs | 208 ++++++++++++++++++++++-- 3 files changed, 212 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..dc6b165d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · The Typography panel controls every OpenType feature a character format carries: Other Features lists Proportional Metrics, Horizontal Kana, Localized Forms, Mark Positioning, Justification Alternates and Roman Italics. Lining, Oldstyle, Tabular or Proportional chosen alone also sets the other figure axis, so IDML export keeps the figure style instead of writing Default. Standard Ligatures follows and sets Character › Ligatures. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 432297f35..adf297311 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1454,6 +1454,7 @@ struct FeatureSegment { len: usize, font: (String, String), applied: Vec, + ligatures: bool, } /// The OpenType features of the fonts in the selection, with how much of the selection each one is @@ -1477,13 +1478,13 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result { let Some(para) = story.paras.get(story.para_at(pos)) else { continue }; let (_, base) = st.doc.styles.resolve_para(para); let cp = st.doc.styles.resolve_char(&base, &cf); - segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features }); + segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); } } if segments.is_empty() { let (_, base) = st.doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH); let cp = st.doc.styles.resolve_char(&base, &designcraft_doc::story::CharFormat::default()); - segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features }); + segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); } let db = designcraft_fonts::FontDb::global(); let mut fonts: std::collections::BTreeMap<(String, String), designcraft_fonts::FontFeatures> = std::collections::BTreeMap::new(); @@ -1500,7 +1501,9 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result { let measure = |tag: &str| { let (mut applied, mut available) = (0, 0); for seg in &segments { - if designcraft_doc::otf::is_on(&seg.applied, tag) { + // Standard ligatures are the `ligatures` attribute, which shaping reads. + let on = if tag == "liga" { seg.ligatures } else { designcraft_doc::otf::is_on(&seg.applied, tag) }; + if on { applied += seg.len; } if fonts.get(&seg.font).is_some_and(|f| f.tags.contains(tag)) { @@ -2828,6 +2831,20 @@ mod open_type_features_tests { let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); assert_eq!(feature(&v, "lnum")["applied"], "none", "{v}"); } + + #[test] + fn standard_ligatures_follow_the_ligatures_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "office"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 6})).unwrap(); + s.execute("type.char", &json!({"attrs": {"ligatures": false}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "none", "Character › Ligatures off: {v}"); + s.execute("type.char", &json!({"attrs": {"ligatures": true}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "all", "{v}"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs index ef34a0110..5cd0814f8 100644 --- a/crates/ui-egui/src/panels/typography.rs +++ b/crates/ui-egui/src/panels/typography.rs @@ -1,7 +1,8 @@ //! The Typography panel: the OpenType features the fonts of the selected text offer, grouped as //! Affinity's are. Every feature is a checkbox that shows how much of the selection it is applied to //! (checked: all of it; a dash: some of it; empty: none). A feature only some of the selection's fonts -//! offer is italic. Changes apply to the selection through `type.feature`. +//! offer is italic. Changes apply to the selection through `type.feature`, and Standard Ligatures +//! through `type.char`'s `ligatures` attribute, which shaping reads. use std::collections::BTreeMap; @@ -46,9 +47,16 @@ const ALTERNATES: &[Switch] = &[ ("zero", "Slashed Zero"), ]; -const FIGURE_STYLE: &[Choice] = &[("Lining", &["lnum"]), ("Oldstyle", &["onum"])]; +/// One axis of the figures (style or width), and the choice that partners a choice on the other +/// axis when no choice on this one applies to all of the selection. +struct FigureAxis { + choices: &'static [Choice], + fallback: usize, +} -const FIGURE_WIDTH: &[Choice] = &[("Tabular", &["tnum"]), ("Proportional", &["pnum"])]; +const FIGURE_STYLE: FigureAxis = FigureAxis { choices: &[("Lining", &["lnum"]), ("Oldstyle", &["onum"])], fallback: 0 }; + +const FIGURE_WIDTH: FigureAxis = FigureAxis { choices: &[("Tabular", &["tnum"]), ("Proportional", &["pnum"])], fallback: 1 }; const FIGURE_POSITION: &[Choice] = &[ ("Numerator", &["numr"]), @@ -62,6 +70,47 @@ const FRACTION_AND_ORDINAL: &[Switch] = &[("frac", "Fractions"), ("ordn", "Ordin const CAPITALS: &[Choice] = &[("Caps to Small Caps", &["c2sc"]), ("Small Caps", &["smcp"]), ("All Small Caps", &["c2sc", "smcp"])]; +/// The sections of on/off switches above Other Features. +const SWITCH_SECTIONS: &[&[Switch]] = &[LIGATURES, ALTERNATES, FRACTION_AND_ORDINAL]; + +/// The character format's on/off features (`otf::TOGGLES`) no other section shows, so each one the +/// character style dialog and IDML carry has a control here too. +fn other_toggles() -> Vec { + designcraft_doc::otf::TOGGLES + .iter() + .filter(|(tag, ..)| !SWITCH_SECTIONS.iter().any(|section| section.iter().any(|(t, _)| t == tag))) + .map(|(tag, label, _)| (*tag, *label)) + .collect() +} + +/// What this frame's clicks set: OpenType feature tags, and the `ligatures` attribute. +#[derive(Default)] +struct Changes { + features: BTreeMap, + ligatures: Option, +} + +impl Changes { + /// Standard Ligatures is the `ligatures` attribute: shaping turns `liga` off through it, and + /// IDML carries it as `Ligatures`, so `liga` stays out of the feature list. + fn set(&mut self, tag: &str, on: bool) { + if tag == "liga" { + self.ligatures = Some(on); + } else { + self.features.insert(tag.to_string(), on); + } + } + + fn apply(self, app: &mut DesignApp) { + if !self.features.is_empty() { + let _ = app.run("type.feature", json!({"features": self.features})); + } + if let Some(on) = self.ligatures { + let _ = app.run("type.char", json!({"attrs": {"ligatures": on}})); + } + } +} + /// A feature the selection's fonts offer, and its state over the selection. #[derive(Clone, Copy)] struct Feature { @@ -118,24 +167,22 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { return; } let lang = app.ui.language.clone(); - // Changes this frame: each feature tag to the state it is set to. - let mut changes: BTreeMap = BTreeMap::new(); + let mut changes = Changes::default(); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Typography")).strong()); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { section(ui, &lang, "Ligatures", |ui| switches(ui, &lang, &offered, LIGATURES, &mut changes)); section(ui, &lang, "Alternates", |ui| switches(ui, &lang, &offered, ALTERNATES, &mut changes)); section(ui, &lang, "Stylistic Sets", |ui| stylistic_sets(ui, &lang, &offered, &mut changes)); - section(ui, &lang, "Figure Style", |ui| choices(ui, &lang, &offered, FIGURE_STYLE, &mut changes)); - section(ui, &lang, "Figure Width", |ui| choices(ui, &lang, &offered, FIGURE_WIDTH, &mut changes)); + section(ui, &lang, "Figure Style", |ui| figures(ui, &lang, &offered, &FIGURE_STYLE, &FIGURE_WIDTH, &mut changes)); + section(ui, &lang, "Figure Width", |ui| figures(ui, &lang, &offered, &FIGURE_WIDTH, &FIGURE_STYLE, &mut changes)); section(ui, &lang, "Figure Position", |ui| { choices(ui, &lang, &offered, FIGURE_POSITION, &mut changes); switches(ui, &lang, &offered, FRACTION_AND_ORDINAL, &mut changes); }); section(ui, &lang, "Capitals", |ui| choices(ui, &lang, &offered, CAPITALS, &mut changes)); + section(ui, &lang, "Other Features", |ui| switches(ui, &lang, &offered, &other_toggles(), &mut changes)); }); - if !changes.is_empty() { - let _ = app.run("type.feature", json!({"features": changes})); - } + changes.apply(app); } /// A collapsible section, open at first. Its body runs only when it has something to show. @@ -157,23 +204,23 @@ fn tri_checkbox(ui: &mut egui::Ui, text: &str, feature: Feature) -> Option } /// On/off switches the fonts offer. -fn switches(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Switch], changes: &mut BTreeMap) { +fn switches(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Switch], changes: &mut Changes) { for (tag, label) in items { let Some(feature) = offered.get(tag) else { continue }; if let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) { - changes.insert(tag.to_string(), on); + changes.set(tag, on); } } } /// Mutually exclusive choices, as checkboxes: choosing one turns the others off. Choosing a checked /// one turns it off. -fn choices(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Choice], changes: &mut BTreeMap) { +fn choices(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Choice], changes: &mut Changes) { for (i, (label, tags)) in items.iter().enumerate() { let Some(feature) = offered.choice(tags) else { continue }; let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) else { continue }; for t in *tags { - changes.insert(t.to_string(), on); + changes.set(t, on); } if on { for (j, (_, others)) in items.iter().enumerate() { @@ -181,23 +228,55 @@ fn choices(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Choice], c continue; } for t in others.iter().filter(|t| !tags.contains(t)) { - changes.insert(t.to_string(), false); + changes.set(t, false); } } } } } +/// A figure style or width, as checkboxes like `choices`. +fn figures(ui: &mut egui::Ui, lang: &str, offered: &Offered, axis: &FigureAxis, other: &FigureAxis, changes: &mut Changes) { + for (i, (label, tags)) in axis.choices.iter().enumerate() { + let Some(feature) = offered.choice(tags) else { continue }; + if let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) { + figure_change(offered, axis, other, i, on, changes); + } + } +} + +/// The changes turning `choice` on `axis` on or off makes. IDML's figure styles pair a style with a +/// width, and a style or width alone is the default figures there, so turning one on also sets the +/// other axis: the choice all of the selection has, else that axis's fallback. Turning one off +/// clears both axes (the font's default figures). +fn figure_change(offered: &Offered, axis: &FigureAxis, other: &FigureAxis, choice: usize, on: bool, changes: &mut Changes) { + for (_, tags) in axis.choices.iter().chain(other.choices) { + for t in *tags { + changes.set(t, false); + } + } + let Some((_, tags)) = axis.choices.get(choice).filter(|_| on) else { return }; + let partner = other + .choices + .iter() + .find(|(_, t)| offered.choice(t).is_some_and(|f| f.applied == Covered::All)) + .or_else(|| other.choices.get(other.fallback)) + .map_or(&[][..], |(_, t)| *t); + for t in tags.iter().chain(partner) { + changes.set(t, true); + } +} + /// Stylistic sets by the name their fonts give them, or the generic `Stylistic Set NN` when the /// selection's fonts name them differently. -fn stylistic_sets(ui: &mut egui::Ui, lang: &str, offered: &Offered, changes: &mut BTreeMap) { +fn stylistic_sets(ui: &mut egui::Ui, lang: &str, offered: &Offered, changes: &mut Changes) { for set in &offered.sets { let label = match &set.name { Some(name) => format!("{name} ({})", set.tag), None => format!("{} {:02}", crate::i18n::tr(lang, "Stylistic Set"), set.number), }; if let Some(on) = tri_checkbox(ui, &label, set.feature) { - changes.insert(set.tag.clone(), on); + changes.set(&set.tag, on); } } } @@ -223,3 +302,98 @@ fn offered(app: &mut DesignApp) -> Offered { } out } + +#[cfg(test)] +mod tests { + use std::io::Read; + + use egui::vec2; + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + /// A Typography panel over "office" (all selected), its fonts installed before the first frame. + fn harness() -> Harness<'static, DesignApp> { + 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": "office 1024", "caret": true})).unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + fn click(h: &mut Harness<'static, DesignApp>, label: &str) { + h.get_by_label(label).click(); + h.run_steps(2); + } + + fn chars(h: &mut Harness<'static, DesignApp>) -> Value { + super::super::text_attrs(h.state_mut()).unwrap()["chars"].clone() + } + + /// Every `OTFFigureStyle` the stories' IDML export writes is `expected`. + fn assert_idml_figures(h: &Harness<'static, DesignApp>, expected: &str, why: &str) { + let idml = designcraft_idml::export_idml(&h.state().session.active().unwrap().doc); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(idml)).unwrap(); + let mut stories = String::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + if f.name().starts_with("Stories/") { + f.read_to_string(&mut stories).unwrap(); + } + } + let found: Vec<&str> = stories.split("OTFFigureStyle=\"").skip(1).filter_map(|s| s.split('"').next()).collect(); + assert!(!found.is_empty() && found.iter().all(|f| *f == expected), "{why}: {found:?}"); + } + + #[test] + fn every_character_format_toggle_has_a_control() { + let mut tags: Vec<&str> = SWITCH_SECTIONS.iter().flat_map(|s| s.iter().map(|(t, _)| *t)).collect(); + tags.extend(other_toggles().iter().map(|(t, _)| *t)); + for (tag, ..) in designcraft_doc::otf::TOGGLES { + assert!(tags.contains(tag), "{tag} has no Typography panel control"); + } + assert_eq!(tags.len(), tags.iter().collect::>().len(), "a feature shown twice: {tags:?}"); + // Source Serif 4 offers `mark`, one of the toggles only Other Features shows. + let mut h = harness(); + click(&mut h, "Mark Positioning"); + assert_eq!(chars(&mut h)["otfFeatures"], json!(["-mark"])); + } + + #[test] + fn a_figure_style_or_width_chosen_alone_exports_as_a_figure_style() { + let mut h = harness(); + click(&mut h, "Lining"); + assert_idml_figures(&h, "ProportionalLining", "Lining takes the fallback width"); + click(&mut h, "Tabular"); + assert_idml_figures(&h, "TabularLining", "Tabular keeps the applied style"); + click(&mut h, "Oldstyle"); + assert_idml_figures(&h, "TabularOldstyle", "Oldstyle keeps the applied width"); + click(&mut h, "Oldstyle"); + assert_idml_figures(&h, "Default", "turning a choice off clears both axes"); + assert!(!["tnum", "pnum", "lnum", "onum"].iter().any(|t| chars(&mut h)["otfFeatures"].as_array().unwrap().contains(&json!(t)))); + } + + #[test] + fn standard_ligatures_switch_the_ligatures_attribute() { + let mut h = harness(); + click(&mut h, "Standard Ligatures"); + let c = chars(&mut h); + assert_eq!(c["ligatures"], false, "{c}"); + assert!(!c["otfFeatures"].as_array().is_some_and(|l| l.iter().any(|t| t.as_str().is_some_and(|t| t.ends_with("liga")))), "{c}"); + click(&mut h, "Standard Ligatures"); + assert_eq!(chars(&mut h)["ligatures"], true, "the switch turns ligatures back on"); + } +} From 04420fd25c83d507c3d0a6d3ad5519cff97847be Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:16:35 +0000 Subject: [PATCH 122/128] Compose: progress-log line for the Japanese composer gating --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..1415b50a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · The CJK leading model and character alignment take effect only in paragraphs set with a Japanese composer (`HL Composer J`, `HL Single J`), which IDML import and export keep: Western and Arabic paragraphs set baseline to baseline on the Roman baseline, a paragraph switched to a Japanese composer gets the file's Aki Below spacing, and under Aki Below a line's leading is the space below it. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. From ae19df8ef2cb1f524ca85d0aed4f610000901a29 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:30:20 +0000 Subject: [PATCH 123/128] fix(typography): Standard Ligatures switches on over a feature-list -liga --- crates/engine/src/cmd/text.rs | 8 ++++++-- crates/ui-egui/src/panels/typography.rs | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index adf297311..af35bc833 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1501,8 +1501,9 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result { let measure = |tag: &str| { let (mut applied, mut available) = (0, 0); for seg in &segments { - // Standard ligatures are the `ligatures` attribute, which shaping reads. - let on = if tag == "liga" { seg.ligatures } else { designcraft_doc::otf::is_on(&seg.applied, tag) }; + // Standard ligatures are the `ligatures` attribute, which shaping reads, unless the + // feature list turns `liga` off. + let on = designcraft_doc::otf::is_on(&seg.applied, tag) && (tag != "liga" || seg.ligatures); if on { applied += seg.len; } @@ -2844,6 +2845,9 @@ mod open_type_features_tests { s.execute("type.char", &json!({"attrs": {"ligatures": true}})).unwrap(); let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); assert_eq!(feature(&v, "liga")["applied"], "all", "{v}"); + s.execute("type.feature", &json!({"features": {"liga": false}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "none", "-liga in the feature list: {v}"); } } diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs index 5cd0814f8..4206459d0 100644 --- a/crates/ui-egui/src/panels/typography.rs +++ b/crates/ui-egui/src/panels/typography.rs @@ -92,10 +92,12 @@ struct Changes { impl Changes { /// Standard Ligatures is the `ligatures` attribute: shaping turns `liga` off through it, and - /// IDML carries it as `Ligatures`, so `liga` stays out of the feature list. + /// IDML carries it as `Ligatures`, so `liga` stays out of the feature list. The tag is also + /// cleared from the selection's features, since an earlier `-liga` would keep shaping it off. fn set(&mut self, tag: &str, on: bool) { if tag == "liga" { self.ligatures = Some(on); + self.features.insert("liga".to_string(), true); } else { self.features.insert(tag.to_string(), on); } @@ -396,4 +398,16 @@ mod tests { click(&mut h, "Standard Ligatures"); assert_eq!(chars(&mut h)["ligatures"], true, "the switch turns ligatures back on"); } + + #[test] + fn standard_ligatures_switch_on_clears_a_feature_list_liga_off() { + let mut h = harness(); + h.state_mut().run("type.feature", json!({"features": {"liga": false}})).unwrap(); + assert_eq!(chars(&mut h)["otfFeatures"], json!(["-liga"])); + h.run_steps(2); + click(&mut h, "Standard Ligatures"); + let c = chars(&mut h); + assert_eq!(c["ligatures"], true, "{c}"); + assert!(!c["otfFeatures"].as_array().is_some_and(|l| l.iter().any(|t| t.as_str().is_some_and(|t| t.ends_with("liga")))), "{c}"); + } } From c275878087ee5da4fe42f2cbd63905de363d5ad4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:30:51 +0000 Subject: [PATCH 124/128] fix(paths): Join Path with one of two paths locked refuses instead of closing the other --- crates/engine/src/cmd/anchors.rs | 9 +++++---- crates/engine/src/cmd/object.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index f2225c480..72b2f085b 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -84,10 +84,11 @@ fn join_paths(s: &mut Session, p: &Value) -> Result { s.edit(|d, sel| { let paths: Vec = ids.iter().copied().filter(|id| d.item(*id).is_some_and(|it| !it.is_group() && it.text_frame().is_none())).collect(); let ids: Vec = paths.iter().copied().filter(|id| path_editable(d, *id, "path.join").is_ok()).collect(); - if ids.is_empty() - && let Some(id) = paths.first() - { - path_editable(d, *id, "path.join")?; + // A request for two paths never falls back to closing the one that is editable. + if ids.len() < paths.len().min(2) { + for id in &paths { + path_editable(d, *id, "path.join")?; + } } let open = |d: &Document, id: ItemId| d.item(id).and_then(|it| it.path.subpaths.iter().position(|sp| !sp.closed && sp.anchors.len() > 1)); match ids.as_slice() { diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index a05024388..dd469d8aa 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3426,6 +3426,24 @@ mod path_edit_tests { assert_no_path_edit(&mut s, id, "on a locked layer"); } + #[test] + fn joining_two_paths_with_one_on_a_locked_layer_changes_neither() { + let (mut s, a) = session_with_path(); + let b = s.execute("path.create", &json!({"anchors": [{"p": [300, 200]}, {"p": [400, 300]}]})).unwrap()["id"].as_u64().unwrap(); + let layer = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setLayer", &json!({"ids": [b], "layer": layer})).unwrap(); + let a_layer = s.doc().unwrap().doc.item(ItemId(a)).unwrap().layer; + s.execute("layer.set", &json!({"id": a_layer.0, "locked": true})).unwrap(); + let (before_a, before_b) = (anchors(&s, a), anchors(&s, b)); + for ids in [[a, b], [b, a]] { + assert!(s.execute("path.join", &json!({"ids": ids})).is_err(), "{ids:?}"); + } + assert_eq!((anchors(&s, a), anchors(&s, b)), (before_a, before_b)); + for id in [a, b] { + assert!(!s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].closed, "{id} stays open"); + } + } + #[test] fn path_edits_leave_a_path_in_a_locked_group_alone() { let (mut s, id) = session_with_path(); From 314e535c37a8c4ab87e649aef2c59ece9053d5fd Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:31:23 +0000 Subject: [PATCH 125/128] fix(type): kerning at the end of a paragraph or story is kept for typing, not applied to the last character --- crates/engine/src/cmd/text.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 64142cdd9..94a9323af 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1162,10 +1162,12 @@ fn kern_pair(s: &mut Session, p: &Value) -> Result { format_chars(s, &json!({ "kerning": kerning })) } -/// Byte offset of the character before `pos` when it starts a kerning pair with the text at `pos` -/// (not a paragraph break). +/// Byte offset of the character before `pos` when it starts a kerning pair with the character at +/// `pos`: both exist and neither is a paragraph or line break. fn pair_start(text: &str, pos: usize) -> Option { - let c = text.get(..pos)?.chars().next_back().filter(|c| *c != '\n')?; + let is_text = |c: &char| !matches!(*c, '\n' | designcraft_doc::story::FORCED_LINE_BREAK); + text.get(pos..)?.chars().next().filter(is_text)?; + let c = text.get(..pos)?.chars().next_back().filter(is_text)?; pos.checked_sub(c.len_utf8()) } @@ -3143,4 +3145,19 @@ mod kern_pair_tests { s.execute("text.insert", &json!({"text": "X"})).unwrap(); assert_eq!(kern_at(&s, sid, 0), Some(Kerning::None)); } + + #[test] + fn kerning_at_the_end_of_a_paragraph_or_story_leaves_the_text_alone() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "AV\nTA"})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + for caret in [2, 5] { + s.execute("text.select", &json!({"story": sid.0, "anchor": caret, "focus": caret})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": -80}})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no pair at {caret}: nothing is edited"); + assert_eq!((kern_at(&s, sid, 1), kern_at(&s, sid, 4)), (None, None), "caret {caret}"); + } + } } From e22205039c1615f71a76e1c88b4e9da811c3c29f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:40:32 +0000 Subject: [PATCH 126/128] test(idml): the liquid-rule round trip detaches page 1's parent along with its local margins --- crates/idml/src/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 042a0c8d9..112d258de 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2549,6 +2549,8 @@ mod liquid_page_rules { let mut source = baseline.clone(); let pages = &mut Arc::make_mut(&mut source.spreads[0]).pages; pages[0].parent = None; + // Without a parent there are no margins to follow, so they aren't local either. + pages[0].local_margins = false; pages[0].liquid = R::ControlledByParent; pages[1].liquid = rule; Arc::make_mut(&mut source.parents[0]).pages[1].liquid = R::ReCenter; From 61d625bd903e2c1dda2923f1193cf62e2292abc3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:23:55 +0200 Subject: [PATCH 127/128] Keep '-' after an RTF control word when no digit follows Found while testing crates/textimport/src/rtf.rs:160 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/rtf.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index 91a5ae5a3..719494a37 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -157,7 +157,9 @@ pub fn import(bytes: &[u8]) -> Result { } let word: String = chars[i + 1..j].iter().collect(); let mut k = j; - if k < chars.len() && (chars[k] == '-' || chars[k].is_ascii_digit()) { + // A '-' only starts the parameter when a digit follows; otherwise it is text. + let negative = chars.get(k) == Some(&'-') && chars.get(k + 1).is_some_and(char::is_ascii_digit); + if negative || chars.get(k).is_some_and(char::is_ascii_digit) { k += 1; while k < chars.len() && chars[k].is_ascii_digit() { k += 1; @@ -293,6 +295,14 @@ Caf\'e9 \u8364? price\tab tab\line next\par assert!(!st.text.contains("Times"), "font table skipped"); } + /// A '-' right after a control word is text unless a digit follows it (then it is a negative + /// parameter). It used to be swallowed. + #[test] + fn hyphen_after_control_word_without_digits_is_text() { + let i = import(br"{\rtf1\ansi Rock\emdash-roll, x\tab-y, a\li-720 b}").unwrap(); + assert_eq!(i.story.text, "Rock\u{2014}-roll, x\t-y, ab"); + } + /// 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] From a184db6f8f2d11bc20832c70ac438576d3d2dd1e Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 15 Oct 2026 19:18:11 -0600 Subject: [PATCH 128/128] fix(export): reject oversized page selections (#1089) Reject page selections that cannot fit the target index type before conversion, and return an error for the largest direct PPTX page index. Validated the combined main and PR tree with all seven cargo xtask ci stages (1,967 passing tests), four actual WASM export-page regressions, and successful FreeBSD, Windows ARM64, and packaging workflows. --- crates/engine/src/cmd/export.rs | 16 ++++-- crates/engine/tests/export_page_input.rs | 62 ++++++++++++++++++++++++ crates/pptx/src/lib.rs | 2 +- crates/pptx/src/tests.rs | 8 +++ 4 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 crates/engine/tests/export_page_input.rs diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f10f3037d..bda757987 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -288,14 +288,22 @@ fn pages_param(id: &str, p: &Value, page_count: usize) -> Result { let mut v = Vec::new(); for x in a { - let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {x}")))?; - v.push(n as usize - 1); + let n = x + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .filter(|n| *n >= 1 && *n <= page_count) + .ok_or_else(|| bad(id, format!("bad page {x}")))?; + v.push(n - 1); } Some(v) } Some(Value::Number(n)) => { - let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {n}")))?; - Some(vec![n as usize - 1]) + let n = n + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .filter(|n| *n >= 1 && *n <= page_count) + .ok_or_else(|| bad(id, format!("bad page {n}")))?; + Some(vec![n - 1]) } Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))), }) diff --git a/crates/engine/tests/export_page_input.rs b/crates/engine/tests/export_page_input.rs new file mode 100644 index 000000000..ebf0ac9ae --- /dev/null +++ b/crates/engine/tests/export_page_input.rs @@ -0,0 +1,62 @@ +//! Public export-command page input must be rejected before narrowing on 32-bit targets. +use designcraft_engine::{EngineError, Session}; +use serde_json::json; + +fn rejects(numbers: &[u64]) { + let mut session = Session::new(); + session.execute("file.new", &json!({"pages": 1})).unwrap(); + let before = serde_json::to_value(&session.doc().unwrap().doc).unwrap(); + for command in ["file.exportPdf", "file.exportPptx"] { + for &number in numbers { + for pages in [json!(number), json!([number])] { + #[cfg(target_arch = "wasm32")] + wasm_bindgen_test::console_log!("SAFETY_REJECTION_INPUT {command} {pages}"); + #[cfg(not(target_arch = "wasm32"))] + println!("SAFETY_REJECTION_INPUT {command} {pages}"); + match session.execute(command, &json!({"pages": pages})) { + Err(EngineError::BadParams { cmd, msg }) => { + assert_eq!(cmd, command); + assert_eq!(msg, format!("bad page {number}")); + } + Ok(_) => panic!("EXPECTED_BAD_PARAMS: {command} pages {pages} was accepted"), + Err(other) => panic!("EXPECTED_BAD_PARAMS: {command} pages {pages} returned {other:?}"), + } + assert_eq!(before, serde_json::to_value(&session.doc().unwrap().doc).unwrap()); + } + } + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn zero_and_maximum_export_pages_are_bad_params() { + rejects(&[0, u64::MAX]); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn page_value_that_truncates_to_valid_page_is_bad_params() { + rejects(&[(1u64 << 32) + 1]); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn page_value_that_truncates_to_zero_is_bad_params() { + rejects(&[1u64 << 32]); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn page_one_exports_in_scalar_and_array_forms() { + let mut session = Session::new(); + session.execute("file.new", &json!({"pages": 1})).unwrap(); + let before = serde_json::to_value(&session.doc().unwrap().doc).unwrap(); + for (command, count) in [("file.exportPdf", "pages"), ("file.exportPptx", "slides")] { + for pages in [json!(1), json!([1])] { + let result = session.execute(command, &json!({"pages": pages})).unwrap(); + assert_eq!(result[count], 1); + assert!(!result["base64"].as_str().unwrap().is_empty()); + assert_eq!(before, serde_json::to_value(&session.doc().unwrap().doc).unwrap()); + } + } +} diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index c62bb707e..e05d05117 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -91,7 +91,7 @@ pub fn export_pptx(doc: &Document, cache: &Cache, opts: &PptxOptions) -> Result< return Err(PptxError::NoPages); } if let Some(bad) = pages.iter().find(|p| **p >= count) { - return Err(PptxError::BadPage(bad + 1)); + return Err(PptxError::BadPage(bad.saturating_add(1))); } let mut ex = slide::Exporter::new(doc, cache, opts); let mut slides = Vec::with_capacity(pages.len()); diff --git a/crates/pptx/src/tests.rs b/crates/pptx/src/tests.rs index 98e78384e..bb00fbd48 100644 --- a/crates/pptx/src/tests.rs +++ b/crates/pptx/src/tests.rs @@ -228,6 +228,14 @@ fn pictures_are_cropped_to_their_frames() { assert!(rels.contains("../media/image1.png")); } +#[test] +fn maximal_page_index_is_an_error() { + let d = Document::new(&NewDocument::default()); + let options = PptxOptions { pages: Some(vec![usize::MAX]), ..Default::default() }; + let result = export_pptx(&d, &Cache::new(), &options); + assert!(matches!(result, Err(PptxError::BadPage(usize::MAX)))); +} + #[test] fn bad_pages_and_hostile_geometry_are_errors_not_crashes() { let mut d = Document::new(&NewDocument::default());