From a62188aa9b659f00d0b4e9b31db58882e9417ab6 Mon Sep 17 00:00:00 2001 From: con3code Date: Sun, 11 Oct 2026 07:47:05 +0900 Subject: [PATCH 001/200] UI: CJK interface text from installed fonts, on the Latin baseline Without the craft-fonts build input the interface had no CJK glyphs: egui can't find system fonts, so Japanese and Chinese menus and labels drew as boxes, and Korean and Traditional Chinese font names in the font menus did in every build. - The first installed UI face of each script (Japanese, Simplified Chinese, Traditional Chinese, Korean; macOS, Windows and Linux families) is added as a fallback for the scripts craft-fonts doesn't cover. - A fallback face sits on the UI face's baseline: egui centres a fallback's line height in the primary's, so a CJK face with a taller ascent or a line gap sat above or below the Latin text beside it. The tweak is computed from the two faces' line metrics. - The interface language's script comes first, before the built-in CJK faces, so Han characters take that language's forms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/fonts/src/lib.rs | 10 ++ crates/ui-egui/src/rtl.rs | 4 +- crates/ui-egui/src/theme.rs | 227 +++++++++++++++++++++++++++++++++++- 3 files changed, 235 insertions(+), 6 deletions(-) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index c1d744780..43d5837ff 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -81,6 +81,16 @@ pub struct ShapedGlyph { } /// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`. +/// Line metrics of the font at `index` of `data`, in font units: (units per em, ascent, descent +/// (negative below the baseline), line gap) — what text layouts such as egui's build lines from. +/// `None` if the font can't be read. +pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> { + use skrifa::MetadataProvider; + let f = skrifa::FontRef::from_index(data, index).ok()?; + let m = f.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading)) +} + pub fn feature(tag: &str) -> Option { let (on, t) = match tag.strip_prefix('-') { Some(r) => (false, r), diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index 2521aae20..79f130043 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -300,7 +300,9 @@ mod tests { fn with_fonts(test: impl FnOnce(&egui::Context)) { let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx, "ar"); + // The built-in faces only: installed fonts (the CJK fallbacks) would make the glyphs + // measured here depend on the machine. + ctx.set_fonts(crate::theme::font_definitions(designcraft_fonts::CRAFT_FONTS, "ar")); ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() }); test(&ctx); let mut output = ctx.end_pass(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3dd50180f..eb294d575 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -255,7 +255,125 @@ impl Tokens { /// Install the UI fonts for the interface language `lang` (it orders the CJK fallbacks). pub fn install_fonts(ctx: &egui::Context, lang: &str) { - ctx.set_fonts(font_definitions(designcraft_fonts::CRAFT_FONTS, lang)); + let craft = designcraft_fonts::CRAFT_FONTS; + let mut defs = font_definitions(craft, lang); + add_system_fallbacks(&mut defs, &system_ui_fonts(craft), lang); + ctx.set_fonts(defs); +} + +/// An installed font the UI falls back to for a script the built-in faces lack. +pub(crate) struct SystemFont { + /// "Jpan", "Hans", "Hant" or "Kore". + script: &'static str, + family: String, + bytes: Vec, + index: u32, +} + +/// Installed UI faces (sans serif) per script, the first one found of each list: macOS, then +/// Windows, then Linux families. +const SYSTEM_UI_FONTS: &[(&str, &[&str])] = &[ + ( + "Jpan", + &[ + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Yu Gothic UI", + "Yu Gothic", + "Meiryo", + "MS Gothic", + "Noto Sans CJK JP", + "Noto Sans JP", + "Source Han Sans JP", + "IPAexGothic", + "Droid Sans Fallback", + ], + ), + ("Hans", &["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]), + // Traditional Chinese and Korean: font menus list those families under their native names. + ("Hant", &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Source Han Sans TC"]), + ("Kore", &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "UnDotum"]), +]; + +/// The installed faces for the CJK scripts `craft` (the built-in craft-fonts) doesn't cover, so +/// the interface shows Japanese and Chinese without the craft-fonts build input, and Korean and +/// Traditional Chinese (font names in the font menus) in any build. egui can't +/// find system fonts itself; the document font scan (not on the web) does. +fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Vec { + let db = designcraft_fonts::FontDb::global(); + SYSTEM_UI_FONTS + .iter() + .filter(|(script, _)| !craft.iter().any(|f| f.scripts.contains(script))) + .filter_map(|(script, families)| { + let family = families.iter().find(|f| db.has_family(f))?; + let face = db.face(family, "Regular"); + // `face` falls back to another family when this one can't be read: keep only the real one. + (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty()).then(|| SystemFont { + script, + family: (*family).to_string(), + bytes: face.data().to_vec(), + index: face.index(), + }) + }) + .collect() +} + +/// The UI face every family starts with (Source Sans 3; the semibold shares its line metrics). +const UI_FONT: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + +/// The tweak that puts a fallback face's baseline on the UI face's. egui centres a fallback's +/// line height in the primary's instead (`pos.y = ascent + (line height − its line height) / 2`), +/// so a CJK face with a taller ascent or a larger line gap would sit above or below the Latin +/// text beside it. Shifts are in ems of the font size. +pub(crate) fn baseline_tweak(face: &[u8], index: u32) -> egui::FontTweak { + let metrics = |data: &[u8], i: u32| { + designcraft_fonts::line_metrics(data, i).map(|(upem, ascent, descent, gap)| (ascent / upem, (ascent - descent + gap) / upem)) + }; + let shift = match (metrics(UI_FONT, 0), metrics(face, index)) { + (Some((ap, hp)), Some((af, hf))) => ap - (af + 0.5 * (hp - hf)), + _ => 0.0, + }; + egui::FontTweak { y_offset_factor: if shift.is_finite() { shift.clamp(-0.5, 0.5) } else { 0.0 }, ..Default::default() } +} + +/// The script whose letterforms the interface language `lang` prefers for Han characters, Kana +/// and Hangul: "Jpan", "Hans", "Hant" or "Kore". Languages without a CJK preference use Japanese +/// forms, the app's default CJK face. +fn cjk_script(lang: &str) -> &'static str { + match lang { + "zh" | "zh-hans" | "zh-cn" => "Hans", + "zh-hant" | "zh-tw" | "zh-hk" => "Hant", + "ko" => "Kore", + _ => "Jpan", + } +} + +/// Add installed faces `system` to every family in the interface language's order: the faces of +/// its script (`cjk_script`) go before the built-in CJK faces, so Han characters take that +/// language's forms (Traditional Chinese, Korean); the others go at the end. +pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[SystemFont], lang: &str) { + let name = |f: &SystemFont| format!("system-{}", f.family); + for f in system { + fonts.font_data.insert( + name(f), + Arc::new(FontData { index: f.index, tweak: baseline_tweak(&f.bytes, f.index), ..FontData::from_owned(f.bytes.clone()) }), + ); + } + let first = cjk_script(lang); + let preferred: Vec = system.iter().filter(|f| f.script == first).map(name).collect(); + let rest: Vec = ["Jpan", "Hans", "Hant", "Kore"] + .iter() + .filter(|s| **s != first) + .flat_map(|s| system.iter().filter(move |f| f.script == *s)) + .map(name) + .collect(); + for (family, stack) in fonts.families.iter_mut() { + // The Arabic families keep their own order (see `font_definitions`). + let arabic = matches!(family, FontFamily::Name(n) if n.starts_with("arabic")); + let at = if arabic { stack.len() } else { stack.iter().position(|n| n.starts_with("craft-")).unwrap_or(stack.len()) }; + stack.splice(at..at, preferred.iter().cloned()); + stack.extend(rest.iter().cloned()); + } } /// The UI fonts: the app's own, then the craft-fonts faces from `craft` (empty without @@ -263,7 +381,7 @@ pub fn install_fonts(ctx: &egui::Context, lang: &str) { /// Simplified Chinese, in the interface language's order, then Arabic. The `arabic` families /// put the Arabic face first for right-to-left runs. egui has no system-font discovery, so /// without craft-fonts CJK and Arabic UI text has no glyphs. -fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], lang: &str) -> FontDefinitions { +pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], lang: &str) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, data: &'static [u8]| { fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(data))); @@ -281,11 +399,15 @@ fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], lang: &str) let chinese = pick("Hans"); let arabic = pick("Arab"); let name = |f: &designcraft_fonts::CraftFont| format!("craft-{}-{}", f.family, f.style); - for f in japanese.iter().chain(&chinese).chain(&arabic) { + for f in japanese.iter().chain(&chinese) { + // CJK faces sit on the Latin baseline (see `baseline_tweak`). + fonts.font_data.insert(name(f), Arc::new(FontData { tweak: baseline_tweak(f.bytes, 0), ..FontData::from_static(f.bytes) })); + } + for f in &arabic { add(&mut fonts, &name(f), f.bytes); } - // Han characters take the forms of the interface language: Chinese faces first for zh. - let zh = lang.starts_with("zh"); + // Han characters take the forms of the interface language: Chinese faces first for Chinese. + let zh = matches!(cjk_script(lang), "Hans" | "Hant"); for (family, stack) in fonts.families.iter_mut() { let bold = *family == FontFamily::Name("semibold".into()); let mut ja = japanese.clone(); @@ -406,6 +528,101 @@ mod japanese_font_tests { assert_eq!(stack.iter().find(|n| n.starts_with("craft-")).map(String::as_str), Some("craft-BIZ UDPGothic-Regular")); } + /// Without craft-fonts, an installed Japanese face gives the interface its glyphs (here a + /// synthetic stand-in, so the test doesn't depend on the machine's fonts). + #[test] + fn installed_fonts_give_japanese_glyphs_without_craft_fonts() { + let bytes = designcraft_fonts::testing::font_with("DC UI Japanese", &['日', '本', '語']).unwrap(); + let system = [super::SystemFont { script: "Jpan", family: "DC UI Japanese".into(), bytes, index: 0 }]; + let mut defs = super::font_definitions(&[], "ja"); + super::add_system_fallbacks(&mut defs, &system, "ja"); + for family in families() { + assert_eq!(defs.families[&family].last().map(String::as_str), Some("system-DC UI Japanese"), "{family:?}"); + } + let ctx = egui::Context::default(); + ctx.set_fonts(defs); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + for ch in "日本語".chars() { + assert!(fonts.has_glyph(&font, ch), "missing {ch}"); + } + }); + } + + /// A fallback face with other line metrics lands on the UI face's baseline: egui puts a glyph + /// at `ascent + (primary line height − its line height) / 2`, plus the tweak. + #[test] + fn fallback_faces_share_the_ui_baseline() { + let ui = designcraft_fonts::line_metrics(super::UI_FONT, 0).unwrap(); + // A synthetic face with a taller ascent and a line gap (Hiragino-like proportions). + let base = designcraft_fonts::testing::font_with("DC Tall", &['日']).unwrap(); + let tall = designcraft_fonts::testing::with_table_u16(base, b"hhea", 4, 1200u16).unwrap(); + let tall = designcraft_fonts::testing::with_table_u16(tall, b"hhea", 8, 500u16).unwrap(); + let face = designcraft_fonts::line_metrics(&tall, 0).unwrap(); + let tweak = super::baseline_tweak(&tall, 0); + let em = |m: (f32, f32, f32, f32)| (m.1 / m.0, (m.1 - m.2 + m.3) / m.0); + let ((ap, hp), (af, hf)) = (em(ui), em(face)); + let baseline = af + 0.5 * (hp - hf) + tweak.y_offset_factor; + assert!((baseline - ap).abs() < 1e-4, "{baseline} vs {ap}"); + assert!(tweak.y_offset_factor.abs() > 0.01, "the synthetic face needs a shift: {}", tweak.y_offset_factor); + // The UI face itself needs no shift. + assert!(super::baseline_tweak(super::UI_FONT, 0).y_offset_factor.abs() < 1e-6); + } + + /// Korean font names (the font menus show Korean families by their Hangul names) get glyphs + /// from an installed Korean face, after the Japanese and Chinese ones. + #[test] + fn installed_korean_faces_give_hangul_glyphs() { + let face = |family: &str, chars: &[char], script| super::SystemFont { + script, + family: family.into(), + bytes: designcraft_fonts::testing::font_with(family, chars).unwrap(), + index: 0, + }; + let system = [face("DC UI Korean", &['한', '글'], "Kore"), face("DC UI Japanese", &['日'], "Jpan")]; + let mut defs = super::font_definitions(&[], "ja"); + super::add_system_fallbacks(&mut defs, &system, "ja"); + let stack = &defs.families[&egui::FontFamily::Proportional]; + let at = |n: &str| stack.iter().position(|x| x == n).unwrap(); + assert!(at("system-DC UI Japanese") < at("system-DC UI Korean")); + let ctx = egui::Context::default(); + ctx.set_fonts(defs); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + assert!(fonts.has_glyph(&font, '한') && fonts.has_glyph(&font, '글')); + }); + } + + /// Han characters take the interface language's forms: its script's installed face comes + /// before the built-in CJK faces and the other scripts' faces. + #[test] + fn interface_language_orders_the_cjk_faces() { + let face = |family: &str, script| super::SystemFont { + script, + family: family.into(), + bytes: designcraft_fonts::testing::font_with(family, &['日']).unwrap(), + index: 0, + }; + let system = [face("DC Ja", "Jpan"), face("DC Hans", "Hans"), face("DC Hant", "Hant"), face("DC Ko", "Kore")]; + for (lang, first) in [("ja", "DC Ja"), ("zh", "DC Hans"), ("zh-hant", "DC Hant"), ("ko", "DC Ko"), ("", "DC Ja")] { + let mut defs = super::font_definitions(&[], lang); + // Stand-in for a built-in CJK face (craft-fonts may be absent in this build). + defs.families.entry(egui::FontFamily::Proportional).or_default().push("craft-Stand-in".into()); + super::add_system_fallbacks(&mut defs, &system, lang); + let stack = &defs.families[&egui::FontFamily::Proportional]; + let at = |n: &str| stack.iter().position(|x| x == n).unwrap(); + let first_at = at(&format!("system-{first}")); + assert!(first_at < at("craft-Stand-in"), "{lang}: {stack:?}"); + for f in &system { + assert!(first_at <= at(&format!("system-{}", f.family)), "{lang}: {stack:?}"); + } + } + } + #[test] fn ui_works_without_craft_fonts() { let ctx = ctx_with(&[]); From 70cfd2d4fa84ef44fd7552cc31ac42bf22a12819 Mon Sep 17 00:00:00 2001 From: con3code Date: Sun, 11 Oct 2026 08:30:39 +0900 Subject: [PATCH 002/200] UI: installed CJK faces are read once, without waiting for the font scan - install_fonts no longer waits for the system font catalog on the first frame: the built-in faces are installed at once, the scan runs in the background, and the installed CJK faces are added when it is done. - The installed faces are read once and shared: changing the interface language reorders them without copying the font files into egui again. - fonts: line_metrics no longer sits between feature's doc comment and feature. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/fonts/src/fontdb.rs | 6 +++ crates/fonts/src/lib.rs | 2 +- crates/ui-egui/src/lib.rs | 14 ++++-- crates/ui-egui/src/theme.rs | 91 ++++++++++++++++++++++--------------- 4 files changed, 73 insertions(+), 40 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..c631f3020 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -881,6 +881,12 @@ impl FontDb { }); } + /// Whether the installed fonts are cataloged: a lookup by family name won't wait for the + /// scan (always true on wasm, which has none). See [`FontDb::scan_in_background`]. + pub fn system_fonts_cataloged(&self) -> bool { + cfg!(target_arch = "wasm32") || self.cataloged.get().is_some() + } + /// Catalog the installed fonts on a background thread, so the first lookup by family name /// (opening a file, the font menus) doesn't wait for the scan. A no-op once they are /// cataloged, and on wasm. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 43d5837ff..630888315 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -80,7 +80,6 @@ pub struct ShapedGlyph { pub rtl: bool, } -/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`. /// Line metrics of the font at `index` of `data`, in font units: (units per em, ascent, descent /// (negative below the baseline), line gap) — what text layouts such as egui's build lines from. /// `None` if the font can't be read. @@ -91,6 +90,7 @@ pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> { Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading)) } +/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`. pub fn feature(tag: &str) -> Option { let (on, t) = match tag.strip_prefix('-') { Some(r) => (false, r), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb81e9c34..29b1ec0b4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -474,6 +474,9 @@ pub struct DesignApp { styled: bool, /// The interface language the UI fonts were installed for (it orders the CJK fallbacks). fonts_lang: String, + /// The installed CJK faces are among the UI fonts (false while the system fonts are still + /// being cataloged: the fonts are installed again when they are). + fonts_system: bool, pub restyle: bool, fonts_ready: bool, pub integrated_titlebar: bool, @@ -514,6 +517,7 @@ impl DesignApp { shot_token: 0, styled: false, fonts_lang: String::new(), + fonts_system: false, restyle: false, fonts_ready: false, integrated_titlebar: false, @@ -726,17 +730,21 @@ impl DesignApp { ctx.set_zoom_factor(scale); } if !self.styled { - theme::install_fonts(ctx, &self.ui.language); + self.fonts_system = theme::install_fonts(ctx, &self.ui.language); self.fonts_lang = self.ui.language.clone(); self.styled = true; self.restyle = true; } else { - if self.fonts_lang != self.ui.language { - theme::install_fonts(ctx, &self.ui.language); + if self.fonts_lang != self.ui.language || (!self.fonts_system && theme::system_fonts_ready()) { + self.fonts_system = theme::install_fonts(ctx, &self.ui.language); self.fonts_lang = self.ui.language.clone(); } self.fonts_ready = true; } + if !self.fonts_system { + // The font scan runs in the background: look again shortly. + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } if self.restyle { theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); self.restyle = false; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index eb294d575..14918008f 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -254,11 +254,23 @@ impl Tokens { } /// Install the UI fonts for the interface language `lang` (it orders the CJK fallbacks). -pub fn install_fonts(ctx: &egui::Context, lang: &str) { +/// Returns false while the installed fonts are still being cataloged: the built-in faces are +/// installed without waiting, and the caller installs again once they are (see +/// [`system_fonts_ready`]) to add the installed CJK faces. +pub fn install_fonts(ctx: &egui::Context, lang: &str) -> bool { let craft = designcraft_fonts::CRAFT_FONTS; let mut defs = font_definitions(craft, lang); - add_system_fallbacks(&mut defs, &system_ui_fonts(craft), lang); + let system = system_ui_fonts(craft); + if let Some(system) = system { + add_system_fallbacks(&mut defs, system, lang); + } ctx.set_fonts(defs); + system.is_some() +} + +/// Whether [`install_fonts`] would find the installed fonts cataloged. +pub fn system_fonts_ready() -> bool { + designcraft_fonts::FontDb::global().system_fonts_cataloged() } /// An installed font the UI falls back to for a script the built-in faces lack. @@ -266,8 +278,16 @@ pub(crate) struct SystemFont { /// "Jpan", "Hans", "Hant" or "Kore". script: &'static str, family: String, - bytes: Vec, - index: u32, + /// The face as egui takes it, on the UI face's baseline. Shared: installing the fonts again + /// (another interface language) doesn't copy the file. + data: Arc, +} + +impl SystemFont { + pub(crate) fn new(script: &'static str, family: String, bytes: Vec, index: u32) -> Self { + let tweak = baseline_tweak(&bytes, index); + SystemFont { script, family, data: Arc::new(FontData { index, tweak, ..FontData::from_owned(bytes) }) } + } } /// Installed UI faces (sans serif) per script, the first one found of each list: macOS, then @@ -299,23 +319,29 @@ const SYSTEM_UI_FONTS: &[(&str, &[&str])] = &[ /// the interface shows Japanese and Chinese without the craft-fonts build input, and Korean and /// Traditional Chinese (font names in the font menus) in any build. egui can't /// find system fonts itself; the document font scan (not on the web) does. -fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Vec { +fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option<&'static [SystemFont]> { + static FACES: std::sync::OnceLock> = std::sync::OnceLock::new(); let db = designcraft_fonts::FontDb::global(); - SYSTEM_UI_FONTS - .iter() - .filter(|(script, _)| !craft.iter().any(|f| f.scripts.contains(script))) - .filter_map(|(script, families)| { - let family = families.iter().find(|f| db.has_family(f))?; - let face = db.face(family, "Regular"); - // `face` falls back to another family when this one can't be read: keep only the real one. - (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty()).then(|| SystemFont { - script, - family: (*family).to_string(), - bytes: face.data().to_vec(), - index: face.index(), + // Never wait for the font scan here (the first frame): it runs in the background, and the + // faces are read once, when it is done. + // (Tests wait instead, so that the fonts don't change in the middle of one.) + if !db.system_fonts_cataloged() && !cfg!(test) { + db.scan_in_background(); + return None; + } + Some(FACES.get_or_init(|| { + SYSTEM_UI_FONTS + .iter() + .filter(|(script, _)| !craft.iter().any(|f| f.scripts.contains(script))) + .filter_map(|(script, families)| { + let family = families.iter().find(|f| db.has_family(f))?; + let face = db.face(family, "Regular"); + // `face` falls back to another family when this one can't be read: keep only the real one. + (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty()) + .then(|| SystemFont::new(script, (*family).to_string(), face.data().to_vec(), face.index())) }) - }) - .collect() + .collect() + })) } /// The UI face every family starts with (Source Sans 3; the semibold shares its line metrics). @@ -354,10 +380,7 @@ fn cjk_script(lang: &str) -> &'static str { pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[SystemFont], lang: &str) { let name = |f: &SystemFont| format!("system-{}", f.family); for f in system { - fonts.font_data.insert( - name(f), - Arc::new(FontData { index: f.index, tweak: baseline_tweak(&f.bytes, f.index), ..FontData::from_owned(f.bytes.clone()) }), - ); + fonts.font_data.insert(name(f), f.data.clone()); } let first = cjk_script(lang); let preferred: Vec = system.iter().filter(|f| f.script == first).map(name).collect(); @@ -533,7 +556,7 @@ mod japanese_font_tests { #[test] fn installed_fonts_give_japanese_glyphs_without_craft_fonts() { let bytes = designcraft_fonts::testing::font_with("DC UI Japanese", &['日', '本', '語']).unwrap(); - let system = [super::SystemFont { script: "Jpan", family: "DC UI Japanese".into(), bytes, index: 0 }]; + let system = [super::SystemFont::new("Jpan", "DC UI Japanese".into(), bytes, 0)]; let mut defs = super::font_definitions(&[], "ja"); super::add_system_fallbacks(&mut defs, &system, "ja"); for family in families() { @@ -575,11 +598,8 @@ mod japanese_font_tests { /// from an installed Korean face, after the Japanese and Chinese ones. #[test] fn installed_korean_faces_give_hangul_glyphs() { - let face = |family: &str, chars: &[char], script| super::SystemFont { - script, - family: family.into(), - bytes: designcraft_fonts::testing::font_with(family, chars).unwrap(), - index: 0, + let face = |family: &str, chars: &[char], script| { + super::SystemFont::new(script, family.into(), designcraft_fonts::testing::font_with(family, chars).unwrap(), 0) }; let system = [face("DC UI Korean", &['한', '글'], "Kore"), face("DC UI Japanese", &['日'], "Jpan")]; let mut defs = super::font_definitions(&[], "ja"); @@ -601,12 +621,8 @@ mod japanese_font_tests { /// before the built-in CJK faces and the other scripts' faces. #[test] fn interface_language_orders_the_cjk_faces() { - let face = |family: &str, script| super::SystemFont { - script, - family: family.into(), - bytes: designcraft_fonts::testing::font_with(family, &['日']).unwrap(), - index: 0, - }; + let face = + |family: &str, script| super::SystemFont::new(script, family.into(), designcraft_fonts::testing::font_with(family, &['日']).unwrap(), 0); let system = [face("DC Ja", "Jpan"), face("DC Hans", "Hans"), face("DC Hant", "Hant"), face("DC Ko", "Kore")]; for (lang, first) in [("ja", "DC Ja"), ("zh", "DC Hans"), ("zh-hant", "DC Hant"), ("ko", "DC Ko"), ("", "DC Ja")] { let mut defs = super::font_definitions(&[], lang); @@ -618,7 +634,10 @@ mod japanese_font_tests { let first_at = at(&format!("system-{first}")); assert!(first_at < at("craft-Stand-in"), "{lang}: {stack:?}"); for f in &system { - assert!(first_at <= at(&format!("system-{}", f.family)), "{lang}: {stack:?}"); + let name = format!("system-{}", f.family); + assert!(first_at <= at(&name), "{lang}: {stack:?}"); + // Another language shares the face's data instead of copying the font file. + assert!(std::sync::Arc::ptr_eq(&defs.font_data[&name], &f.data), "{lang}: {name}"); } } } From 65f0b37920f75d8b443b00388a8b23846634e2c9 Mon Sep 17 00:00:00 2001 From: con3code Date: Sun, 11 Oct 2026 10:00:17 +0900 Subject: [PATCH 003/200] Fonts: system_fonts_cataloged builds on wasm `cfg!` doesn't remove the expression, and `cataloged` only exists off wasm, so `cargo xtask wasm` failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/fonts/src/fontdb.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index c631f3020..60c5835c7 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -884,7 +884,10 @@ impl FontDb { /// Whether the installed fonts are cataloged: a lookup by family name won't wait for the /// scan (always true on wasm, which has none). See [`FontDb::scan_in_background`]. pub fn system_fonts_cataloged(&self) -> bool { - cfg!(target_arch = "wasm32") || self.cataloged.get().is_some() + #[cfg(target_arch = "wasm32")] + return true; + #[cfg(not(target_arch = "wasm32"))] + self.cataloged.get().is_some() } /// Catalog the installed fonts on a background thread, so the first lookup by family name From b522a0a3e8803de4bd91e1af6b5d1b651a1cdaaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wladmir=20J=C3=BAnior?= Date: Sun, 11 Oct 2026 00:44:04 -0300 Subject: [PATCH 004/200] pt-BR: complete the interface translation --- crates/ui-egui/src/i18n/pt_br.rs | 424 +++++++++++++++++++++++++++++++ 1 file changed, 424 insertions(+) diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d25717dc6..1e1c6e612 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1073,4 +1073,428 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules…", "Linhas de parágrafo…"), ("Paragraph Rules", "Linhas de parágrafo"), ("Rule Below", "Linha abaixo"), + ("Can't Open the File", "Não foi possível abrir o arquivo"), + ("Can't Place the File", "Não foi possível inserir o arquivo"), + ("Story Direction", "Direção do texto"), + ("Tate-Chu-Yoko", "Tate-chu-yoko"), + ("Ruby…", "Ruby…"), + ("Kenten", "Kenten"), + ("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"), + ("Align to Object Edges", "Alinhar às bordas dos objetos"), + ("Align to Object Centers", "Alinhar aos centros dos objetos"), + ("Smart Dimensions", "Dimensões inteligentes"), + ("Smart Spacing", "Espaçamento inteligente"), + ("Snap to Zone", "Alinhar à zona"), + ("Info", "Informações"), + ("Index", "Índice remissivo"), + ("Articles", "Artigos"), + ("Tags", "Tags"), + ("Object States", "Estados do objeto"), + ("Liquid Layout", "Layout líquido"), + ("Split Window", "Dividir janela"), + ("New Window", "Nova janela"), + ("CMYK", "CMYK"), + ("Lab", "Lab"), + ("OpenType", "OpenType"), + ("RGB", "RGB"), + ("Accept All Changes", "Aceitar todas as alterações"), + ("Activate Document", "Ativar documento"), + ("Add Anchor Point", "Adicionar ponto de ancoragem"), + ("Add Document", "Adicionar documento"), + ("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)"), + ("Apply Cell Style", "Aplicar estilo de célula"), + ("Apply Condition", "Aplicar condição"), + ("Apply Parent to Pages…", "Aplicar página-mestre às páginas…"), + ("ArtCraft App Page…", "Página do aplicativo ArtCraft…"), + ("ArtCraft Website…", "Site do ArtCraft…"), + ("Attributes", "Atributos"), + ("Bevel and Emboss", "Chanfro e relevo"), + ("Buttons", "Botões"), + ("Cell Style Options…", "Opções de estilo de célula…"), + ("Change All (Object)", "Alterar tudo (objeto)"), + ("Changes", "Alterações"), + ("Character Formatting", "Formatação de caracteres"), + ("Clear Conveyor", "Limpar transportador"), + ("Clear Transformations", "Limpar transformações"), + ("Clipping Path…", "Traçado de recorte…"), + ("Close Book", "Fechar livro"), + ("Close Library", "Fechar biblioteca"), + ("Collect", "Coletar"), + ("Color Settings…", "Configurações de cor…"), + ("Community & Project Links", "Links da comunidade e do projeto"), + ("Container", "Contêiner"), + ("Content", "Conteúdo"), + ("Contextual Task Bar", "Barra de tarefas contextual"), + ("Control", "Controle"), + ("Convert Cell to Graphic Cell", "Converter célula em célula gráfica"), + ("Convert Cell to Graphic Cell…", "Converter célula em célula gráfica…"), + ("Convert Cell to Text Cell", "Converter célula em célula de texto"), + ("Convert Color", "Converter cor"), + ("Convert Direction Point", "Converter ponto de direção"), + ("Convert Note to Text", "Converter nota em texto"), + ("Convert Table to Text", "Converter tabela em texto"), + ("Convert Text to Table", "Converter texto em tabela"), + ("Convert to Button", "Converter em botão"), + ("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"), + ("Cross-References", "Referências cruzadas"), + ("Data Merge Fields", "Campos de mesclagem de dados"), + ("Define Cross-Reference Format…", "Definir formato de referência cruzada…"), + ("Define Lists…", "Definir listas…"), + ("Define Text Variable…", "Definir variável de texto…"), + ("Delete All Guides on Spread", "Excluir todas as guias do spread"), + ("Delete Anchor Point", "Excluir ponto de ancoragem"), + ("Delete Item", "Excluir item"), + ("Delete Text Variable", "Excluir variável de texto"), + ("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"), + ("Document Endnote Options…", "Opções de notas de fim do documento…"), + ("Document Preferences", "Preferências do documento"), + ("Document Presets", "Predefinições de documento"), + ("Document…", "Documento…"), + ("Edit Parents", "Editar páginas-mestre"), + ("Edit Story", "Editar matéria"), + ("Endnotes", "Notas de fim"), + ("Entire Pasteboard", "Toda a mesa de trabalho"), + ("Erase", "Apagar"), + ("Export Book to PDF…", "Exportar livro para PDF…"), + ("Export EPUB (Reflowable)…", "Exportar EPUB (reflowable)…"), + ("Export Selection as Snippet", "Exportar seleção como snippet"), + ("Extend Text Selection", "Estender seleção de texto"), + ("Fast Display", "Exibição rápida"), + ("Find Object", "Localizar objeto"), + ("Find by Script Label", "Localizar por rótulo de script"), + ("First Object Above", "Primeiro objeto acima"), + ("Fit Selection in Window", "Ajustar seleção à janela"), + ("Flattener Preview", "Visualização do achatamento"), + ("Float Panel", "Flutuar painel"), + ("Fonts in Document", "Fontes no documento"), + ("Form Fields", "Campos de formulário"), + ("Frame Fitting Options…", "Opções de ajuste de quadro…"), + ("Generate Index…", "Gerar índice remissivo…"), + ("Generate QR Code…", "Gerar código QR…"), + ("Generate Static Caption", "Gerar legenda estática"), + ("Get Story", "Obter matéria"), + ("Get Table", "Obter tabela"), + ("Global Light…", "Luz global…"), + ("Go To Link", "Ir para o link"), + ("Go To Source", "Ir para a origem"), + ("Gradient Feather", "Esmaecimento em gradiente"), + ("Gridify", "Converter em grade"), + ("Group Transparency", "Transparência do grupo"), + ("Hide Menu Item", "Ocultar item de menu"), + ("Hide/Lock Others", "Ocultar/bloquear outros"), + ("High Quality Display", "Exibição em alta qualidade"), + ("History", "Histórico"), + ("Import Options: Styles", "Opções de importação: estilos"), + ("Index Page References", "Referências de página do índice remissivo"), + ("Inner Glow", "Brilho interno"), + ("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"), + ("Join the ArtCraft Discord…", "Entrar no Discord do ArtCraft…"), + ("Last Object Below", "Último objeto abaixo"), + ("Library Contents", "Conteúdo da biblioteca"), + ("Library Items", "Itens da biblioteca"), + ("Liquid Page Rule", "Regra de página líquida"), + ("List Documents", "Listar documentos"), + ("List Styles", "Listar estilos"), + ("List Tools", "Listar ferramentas"), + ("Load Place Cursor", "Carregar cursor de inserção"), + ("Make Compound Path", "Criar traçado composto"), + ("Map Styles to Tags", "Mapear estilos para tags"), + ("Math Expression SVG", "SVG de expressão matemática"), + ("Media Options", "Opções de mídia"), + ("Menus…", "Menus…"), + ("Merge Layers", "Mesclar camadas"), + ("Move Caret", "Mover cursor de texto"), + ("Move Gap", "Mover espaço"), + ("Move Row", "Mover linha"), + ("New Book", "Novo livro"), + ("New Cell Style…", "Novo estilo de célula…"), + ("New Color Swatch…", "Nova amostra de cor…"), + ("New Condition…", "Nova condição…"), + ("New Guide", "Nova guia"), + ("New Hyperlink Destination…", "Novo destino de hiperlink…"), + ("New Hyperlink…", "Novo hiperlink…"), + ("New Library", "Nova biblioteca"), + ("New Page Reference…", "Nova referência de página…"), + ("New Stroke Style…", "Novo estilo de traço…"), + ("New Table Style…", "Novo estilo de tabela…"), + ("Next Cell", "Próxima célula"), + ("Next Document", "Próximo documento"), + ("Next Object Above", "Próximo objeto acima"), + ("Next Object Below", "Próximo objeto abaixo"), + ("Next Object in Group", "Próximo objeto no grupo"), + ("Object Export Options", "Opções de exportação de objeto"), + ("Object Export Options…", "Opções de exportação de objeto…"), + ("Object Layer Options…", "Opções de camada de objeto…"), + ("Object Layers", "Camadas de objeto"), + ("Object Liquid Settings", "Configurações líquidas do objeto"), + ("Open Book", "Abrir livro"), + ("Open Bytes", "Abrir bytes"), + ("Open IDML", "Abrir IDML"), + ("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"), + ("Paste Into", "Colar dentro"), + ("Paste without Formatting", "Colar sem formatação"), + ("Place Caret", "Posicionar cursor de texto"), + ("Place Item", "Inserir item"), + ("Place Snippet", "Inserir snippet"), + ("Place and Link", "Inserir e vincular"), + ("Polygon Settings…", "Configurações de polígono…"), + ("Preflight Document", "Verificar documento (preflight)"), + ("Previous Cell", "Célula anterior"), + ("Previous Document", "Documento anterior"), + ("Previous Object in Group", "Objeto anterior no grupo"), + ("Printers", "Impressoras"), + ("Proof Colors", "Cores de prova"), + ("Proof Setup", "Configuração de prova"), + ("Quick Apply…", "Aplicação rápida…"), + ("Recover Documents", "Recuperar documentos"), + ("Recovery Data", "Dados de recuperação"), + ("Reject All Changes", "Rejeitar todas as alterações"), + ("Release Compound Path", "Liberar traçado composto"), + ("Remove All Local Overrides", "Remover todas as substituições locais"), + ("Remove Document", "Remover documento"), + ("Report an Issue…", "Relatar um problema…"), + ("Reset Shortcuts", "Redefinir atalhos"), + ("Reset Workspace", "Redefinir espaço de trabalho"), + ("Resize", "Redimensionar"), + ("Rotate Spread", "Girar spread"), + ("Run Script", "Executar script"), + ("Sample Document", "Documento de exemplo"), + ("Satin", "Cetim"), + ("Save Recovery Data", "Salvar dados de recuperação"), + ("Script Label", "Rótulo de script"), + ("Select All on Layer", "Selecionar tudo na camada"), + ("Select Cells", "Selecionar células"), + ("Select Column", "Selecionar coluna"), + ("Select Frame", "Selecionar quadro"), + ("Select Row", "Selecionar linha"), + ("Select Table", "Selecionar tabela"), + ("Select Text", "Selecionar texto"), + ("Select Tool", "Selecionar ferramenta"), + ("Select Word", "Selecionar palavra"), + ("Selection Attributes", "Atributos da seleção"), + ("Separations Preview", "Visualização de separações"), + ("Serialize", "Serializar"), + ("Set Active Layer", "Definir camada ativa"), + ("Set Shortcut", "Definir atalho"), + ("Set Visibility / Lock", "Definir visibilidade / bloqueio"), + ("Show Panel", "Mostrar painel"), + ("Show/Hide Baseline Grid", "Mostrar/ocultar grade de linha de base"), + ("Show/Hide Frame Edges", "Mostrar/ocultar bordas dos quadros"), + ("Show/Hide Guides", "Mostrar/ocultar guias"), + ("Show/Hide Hidden Characters", "Mostrar/ocultar caracteres ocultos"), + ("Show/Hide Panels", "Mostrar/ocultar painéis"), + ("Show/Hide Panels Except Tools", "Mostrar/ocultar painéis, exceto ferramentas"), + ("Show/Hide Rulers", "Mostrar/ocultar réguas"), + ("Show/Hide Tagged Frames", "Mostrar/ocultar quadros marcados"), + ("Show/Hide Text Threads", "Mostrar/ocultar encadeamentos de texto"), + ("Smooth", "Suavizar"), + ("Sort", "Ordenar"), + ("Split Path", "Dividir traçado"), + ("Step and Repeat…", "Passo e repetição…"), + ("Stroke Styles", "Estilos de traço"), + ("Style Source", "Origem do estilo"), + ("Synchronize Book", "Sincronizar livro"), + ("Table Panel", "Painel Tabela"), + ("Table Style Options…", "Opções de estilo de tabela…"), + ("Table of Contents Entries", "Entradas do sumário"), + ("Table of Contents…", "Sumário…"), + ("Text Anchors", "Âncoras de texto"), + ("Text Variables", "Variáveis de texto"), + ("Toggle Normal/Preview", "Alternar entre normal e visualização"), + ("Toggle Selection", "Alternar seleção"), + ("Tools: Double Column", "Ferramentas: coluna dupla"), + ("Transform Panel", "Painel Transformar"), + ("Transform Values", "Valores de transformação"), + ("Transparency Blend Space", "Espaço de mesclagem de transparência"), + ("Transparency Flattener Presets", "Predefinições de achatamento de transparência"), + ("Type on a Path", "Texto em um traçado"), + ("Type on a Path Options…", "Opções de texto em um traçado…"), + ("Typical Display", "Exibição típica"), + ("Update Link", "Atualizar link"), + ("User Dictionary Words", "Palavras do dicionário do usuário"), + ("User Dictionary…", "Dicionário do usuário…"), + ("Zoom To", "Zoom para"), + ("⇧", "⇧"), + ("⌃", "⌃"), + ("⌘", "⌘"), + ("⌥", "⌥"), + ("Accept Change", "Aceitar alteração"), + ("Add Anchor", "Adicionar âncora"), + ("Add Selection to Article", "Adicionar seleção ao artigo"), + ("Add Unnamed Colors", "Adicionar cores sem nome"), + ("Add to Dictionary", "Adicionar ao dicionário"), + ("Align Center", "Alinhar ao centro"), + ("Align Left", "Alinhar à esquerda"), + ("Align Right", "Alinhar à direita"), + ("Apply Character Style", "Aplicar estilo de caractere"), + ("Apply Object Style", "Aplicar estilo de objeto"), + ("Apply Paragraph Style", "Aplicar estilo de parágrafo"), + ("Apply Table Style", "Aplicar estilo de tabela"), + ("Article Options", "Opções de artigo"), + ("Basic Feather", "Esmaecimento básico"), + ("Bold", "Negrito"), + ("Character Style Options…", "Opções de estilo de caractere…"), + ("Close Path", "Fechar traçado"), + ("Color Group Options", "Opções de grupo de cores"), + ("Condition Options…", "Opções de condição…"), + ("Convert to Liquid Guide", "Converter em guia líquida"), + ("Convert to Object", "Converter em objeto"), + ("Create Line", "Criar linha"), + ("Create Path", "Criar traçado"), + ("Decrease Point Size", "Diminuir tamanho em pontos"), + ("Delete Article", "Excluir artigo"), + ("Delete Bookmark", "Excluir marcador"), + ("Delete Endnote", "Excluir nota de fim"), + ("Delete Footnote", "Excluir nota de rodapé"), + ("Delete Guide", "Excluir guia"), + ("Delete Hyperlink", "Excluir hiperlink"), + ("Delete List", "Excluir lista"), + ("Delete Note", "Excluir nota"), + ("Delete Pages", "Excluir páginas"), + ("Delete Paragraph Style", "Excluir estilo de parágrafo"), + ("Delete Stroke Style", "Excluir estilo de traço"), + ("Delete Table", "Excluir tabela"), + ("Delete Tag", "Excluir tag"), + ("Delete Text", "Excluir texto"), + ("Distribute Columns Evenly", "Distribuir colunas uniformemente"), + ("Edit Endnote", "Editar nota de fim"), + ("Edit Note", "Editar nota"), + ("Embed Link", "Incorporar link"), + ("Flip", "Inverter"), + ("Hyperlink Options…", "Opções de hiperlink…"), + ("Increase Point Size", "Aumentar tamanho em pontos"), + ("Insert Column Left", "Inserir coluna à esquerda"), + ("Insert Column Right", "Inserir coluna à direita"), + ("Insert Row Above", "Inserir linha acima"), + ("Insert Row Below", "Inserir linha abaixo"), + ("Italic", "Itálico"), + ("Justify Left", "Justificar à esquerda"), + ("Layer Options…", "Opções de camada…"), + ("Move Anchors", "Mover âncoras"), + ("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"), + ("Reject Change", "Rejeitar alteração"), + ("Release State to Objects", "Liberar estado para objetos"), + ("Remove Hyphenation Exception", "Remover exceção de hifenização"), + ("Remove from Article", "Remover do artigo"), + ("Rename", "Renomear"), + ("Rename Bookmark", "Renomear marcador"), + ("Rotate", "Girar"), + ("Scale", "Escalar"), + ("Set Cross-Reference Format", "Definir formato de referência cruzada"), + ("Set Footnote Text", "Definir texto da nota de rodapé"), + ("Set Story Text", "Definir texto da matéria"), + ("Shear", "Distorcer"), + ("Show All on Spread", "Mostrar tudo no spread"), + ("Show State", "Mostrar estado"), + ("State Options", "Opções de estado"), + ("Tag Frame", "Marcar quadro"), + ("Tag Text", "Marcar texto"), + ("Unlink", "Desvincular"), + ("Update Index", "Atualizar índice remissivo"), + ("Update Table of Contents", "Atualizar sumário"), + ("Bulleted and Numbered Lists", "Listas com marcadores e numeradas"), + ("Hyperlinks Cross-References", "Hiperlinks e referências cruzadas"), + ("Document Footnote Options...", "Opções de notas de rodapé do documento..."), + ("Anchored Object", "Objeto ancorado"), + ("Apply Attributes (Eyedropper", "Aplicar atributos (conta-gotas"), + ("Captions", "Legendas"), + ("Change All (Object", "Alterar tudo (objeto"), + ("Clipping Path", "Traçado de recorte"), + ("Convert Cell Type", "Converter tipo de célula"), + ("Copy Link(s", "Copiar link(s"), + ("DesignCraft", "DesignCraft"), + ("Export EPUB (Fixed Layout", "Exportar EPUB (layout fixo"), + ("Export EPUB (Reflowable", "Exportar EPUB (reflowable"), + ("Hyperlinks & Cross-References", "Hiperlinks e referências cruzadas"), + ("Hyphens and Dashes", "Hifens e travessões"), + ("Markers", "Marcadores"), + ("Other", "Outros"), + ("Output", "Saída"), + ("Quotation Marks", "Aspas"), + ("Symbols", "Símbolos"), + ("Type & Tables", "Texto e tabelas"), + ("Utilities", "Utilitários"), + ("Korean Line Breaks:", "Quebras de linha em coreano:"), + ("Between syllables (not only at spaces)", "Entre sílabas (não apenas nos espaços)"), + ("Show Font Names in English", "Mostrar nomes de fontes em inglês"), + ("Missing Glyphs", "Glifos ausentes"), + ("Draw Missing Glyphs from Fallback Fonts", "Desenhar glifos ausentes com fontes substitutas"), + ("No data source", "Nenhuma fonte de dados"), + ("Select Data Source…", "Selecionar fonte de dados…"), + ("Data Merge", "Mesclagem de dados"), + ("Previous", "Anterior"), + ("Next", "Próximo"), + ("Record", "Registro"), + ("Merge options", "Opções de mesclagem"), + ("All records", "Todos os registros"), + ("One record", "Um registro"), + ("Range", "Intervalo"), + ("Records per page", "Registros por página"), + ("Single record", "Registro único"), + ("Multiple records", "Vários registros"), + ("Rows first", "Linhas primeiro"), + ("Columns first", "Colunas primeiro"), + ("Column spacing", "Espaçamento entre colunas"), + ("Row spacing", "Espaçamento entre linhas"), + ("Fit proportionally", "Ajustar proporcionalmente"), + ("Fill proportionally", "Preencher proporcionalmente"), + ("Fit content to frame", "Ajustar conteúdo ao quadro"), + ("Center in frame", "Centralizar no quadro"), + ("Link images", "Vincular imagens"), + ("Limit", "Limite"), + ("Bind to frame", "Vincular ao quadro"), + ("Insert field", "Inserir campo"), + ("Pass csv, rows, or bytes from the control channel.", "Passe csv, linhas ou bytes pelo canal de controle."), + ("Panels", "Painéis"), ]; From 2b55f2ed0d9e76c8f0327194e850fbc21fe10a29 Mon Sep 17 00:00:00 2001 From: Middle Disc Date: Sun, 11 Oct 2026 12:16:18 +0330 Subject: [PATCH 005/200] 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 0a9bfa57352e4872562487415ab1e3e4bf688f50 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:09:26 +0200 Subject: [PATCH 006/200] UI: Alt + wheel zooms the view at the pointer (#470) The wheel turned over a document view with Alt (Option) held scrolled the view like the plain wheel. It now zooms at the pointer, exactly as Ctrl/Cmd + wheel does. As with that zoom, a wheel motion keeps its key to its end: letting Alt go while a trackpad swipe coasts on does not turn the rest into scrolling. --- crates/ui-egui/src/canvas.rs | 107 +++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e61..f4a4bf438 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -181,6 +181,28 @@ pub fn set_zoom(app: &mut DesignApp, z: f64) { zoom_at(app, rect.center(), z / cur); } +/// Whether the wheel motion now arriving was made with Alt down. As egui does for Ctrl/Cmd, a +/// motion keeps its key to its end: the wheel is smoothed over a few frames, and a trackpad swipe +/// coasts on after the key is let go. +fn wheel_alt(ui: &egui::Ui) -> bool { + let id = egui::Id::new("canvas_wheel_alt"); + // Alt, and whether a trackpad swipe is under way. + let (mut alt, mut swipe): (bool, bool) = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + ui.input(|i| { + for e in &i.events { + if let egui::Event::MouseWheel { phase, modifiers, .. } = e { + (alt, swipe) = match phase { + egui::TouchPhase::Start => (modifiers.alt, true), + egui::TouchPhase::Move => (modifiers.alt || (swipe && alt), swipe), + egui::TouchPhase::End | egui::TouchPhase::Cancel => (false, false), + }; + } + } + }); + ui.data_mut(|d| d.insert_temp(id, (alt, swipe))); + alt +} + pub fn apply_view_request(app: &mut DesignApp, p: &Value) { // Power Zoom (Hand tool, Alt-press): zoom out to the spread, aim a view-sized rectangle, zoom // back in where it was released. @@ -1347,6 +1369,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let wants_text = app.session.wants_text(); let space = ui.input(|i| i.key_down(egui::Key::Space)) && !wants_text && !ui.ctx().text_edit_focused(); // Scroll / zoom. + let alt = wheel_alt(ui); if resp.hovered() { let (scroll, zoom_delta, m, hover) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.pointer.hover_pos())); if let Some(hp) = hover { @@ -1354,6 +1377,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r zoom_at(app, hp, zoom_delta as f64); } else if m.command && scroll.y != 0.0 { zoom_at(app, hp, (1.0 + scroll.y as f64 * 0.003).clamp(0.5, 2.0)); + } else if alt && scroll != egui::Vec2::ZERO { + // Alt + wheel zooms by as much as Ctrl/Cmd + wheel, which egui turns into `zoom_delta`. + let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed); + zoom_at(app, hp, (speed * (scroll.x + scroll.y)).exp() as f64); } else if scroll != egui::Vec2::ZERO && let Some(v) = app.view_mut() { @@ -1767,6 +1794,86 @@ mod tests { h.run_steps(3); } + fn new_document() -> Harness<'static, DesignApp> { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + harness(app) + } + + fn held(modifiers: egui::Modifiers) -> egui::Event { + egui::Event::ModifiersChanged(modifiers) + } + + fn wheel(phase: egui::TouchPhase, up: f32, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, up), phase, modifiers } + } + + // Plays `events` with the pointer at `at`, a frame each, and lets the motion settle. + fn play(h: &mut Harness<'_, DesignApp>, at: Pos2, events: &[egui::Event]) -> View { + h.hover_at(at); + for event in events { + h.event(event.clone()); + h.step(); + } + h.run_steps(30); + *h.state().view().unwrap() + } + + /// Alt + wheel scrolled the view like the plain wheel; it zooms at the pointer (#470). + #[test] + fn alt_wheel_zooms_at_the_pointer() { + use egui::{Modifiers, TouchPhase::Move}; + let mut h = new_document(); + let rect = h.state().canvas_rect.unwrap(); + let at = rect.center() + vec2(150.0, 90.0); + let before = *h.state().view().unwrap(); + let under = Xf::new(rect, &before).to_canvas(at); + let zoomed = play(&mut h, at, &[held(Modifiers::ALT), wheel(Move, 100.0, Modifiers::ALT)]); + assert!(zoomed.zoom > before.zoom * 1.2, "wheel up zooms in: {} -> {}", before.zoom, zoomed.zoom); + let still = Xf::new(rect, &zoomed).to_canvas(at); + assert!((still.x - under.x).abs() < 0.01 && (still.y - under.y).abs() < 0.01, "the point under the pointer stays: {under:?} -> {still:?}"); + let back = play(&mut h, at, &[held(Modifiers::ALT), wheel(Move, -100.0, Modifiers::ALT)]); + assert!((back.zoom - before.zoom).abs() < 1e-6, "wheel down zooms out: {} -> {}", zoomed.zoom, back.zoom); + // The plain wheel still scrolls. + let scrolled = play(&mut h, at, &[held(Modifiers::NONE), wheel(Move, 100.0, Modifiers::NONE)]); + assert!(scrolled.zoom == back.zoom && scrolled.origin.y < back.origin.y, "{back:?} -> {scrolled:?}"); + } + + /// Alt zooms exactly as Ctrl/Cmd does, also when the key is let go while the wheel's motion is + /// still arriving: the rest of it zooms too and does not scroll the page. + #[test] + fn alt_wheel_zooms_like_ctrl_wheel_to_the_end_of_the_motion() { + use egui::{ + Modifiers, + TouchPhase::{End, Move, Start}, + }; + const NONE: Modifiers = Modifiers::NONE; + // Each motion in steps; the views are compared after every step. + let motions: [fn(Modifiers) -> Vec>; 4] = [ + |key| vec![vec![held(key), wheel(Move, 100.0, key)]], + // A wheel turned fast, the key let go a frame later. + |key| vec![vec![held(key), wheel(Move, 400.0, key), held(NONE)]], + // A trackpad swipe that coasts on after the key is let go; once it has ended, the wheel scrolls. + |key| { + vec![ + vec![held(key), wheel(Start, 0.0, key), wheel(Move, 50.0, key), held(NONE), wheel(Move, 200.0, NONE)], + vec![wheel(End, 0.0, NONE), wheel(Move, 100.0, NONE)], + ] + }, + |key| vec![vec![held(key | Modifiers::SHIFT), wheel(Move, 100.0, key | Modifiers::SHIFT)]], + ]; + for (i, motion) in motions.iter().enumerate() { + let (mut with_alt, mut with_command) = (new_document(), new_document()); + let at = with_alt.state().canvas_rect.unwrap().center() + vec2(150.0, 90.0); + let start = *with_alt.state().view().unwrap(); + for (alt, command) in motion(Modifiers::ALT).iter().zip(motion(Modifiers::COMMAND)) { + let (alt, command) = (play(&mut with_alt, at, alt), play(&mut with_command, at, &command)); + assert!(command.zoom > start.zoom, "motion {i} zooms"); + assert_eq!(alt, command, "motion {i}"); + } + } + } + fn units(h: &Harness<'_, DesignApp>) -> (Unit, Unit) { let s = &h.state().session.doc().unwrap().doc.settings; (s.horizontal_units, s.vertical_units) From cde814c0dff8aaa5321b5fe4cccf9fd9f53d9d3d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 12:24:00 +0000 Subject: [PATCH 007/200] Selection tool: Alt and Shift resize around the centre and keep side-handle drags centred --- crates/tools/src/select.rs | 71 +++++++++++++++++--------------------- crates/tools/src/tests.rs | 25 ++++++++++++++ 2 files changed, 57 insertions(+), 39 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8f59c3503..babaa4712 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -178,12 +178,18 @@ fn resize_edges_rect(from: Rect, handle: usize, p: Point) -> Rect { } fn finish_resize(from: Rect, handle: usize, rect: Rect, m: Mods) -> Rect { - let rect = if m.shift && from.width() > 1e-9 && from.height() > 1e-9 { - apply_aspect(from, handle, rect, aspect_scale(from, handle, rect)) - } else { - rect - }; - if m.alt { apply_alt(from, handle, rect) } else { rect } + // Alt first: Shift then measures the drag from the centre and keeps it there. + let rect = if m.alt { apply_alt(from, handle, rect) } else { rect }; + if !(m.shift && from.width() > 1e-9 && from.height() > 1e-9) { + return rect; + } + let s = aspect_scale(from, handle, rect); + if m.alt { centered_rect(from, from.width() * s, from.height() * s) } else { apply_aspect(from, handle, rect, s) } +} + +fn centered_rect(from: Rect, w: f64, h: f64) -> Rect { + let c = from.center(); + Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0) } fn aspect_scale(from: Rect, handle: usize, rect: Rect) -> f64 { @@ -214,31 +220,17 @@ fn drives_x(handle: usize, from: Rect, rect: Rect) -> bool { sx > sy } +/// Scales `from` by `s` with the edges opposite the handle fixed. An axis the handle doesn't move stays centred. fn apply_aspect(from: Rect, handle: usize, rect: Rect, s: f64) -> Rect { - let (mut x0, mut y0, mut x1, mut y1) = (rect.x0, rect.y0, rect.x1, rect.y1); - let (w, h) = (from.width() * s, from.height() * s); - match handle { - 0 => { - x0 = x1 - w; - y0 = y1 - h; - } - 2 => { - x1 = x0 + w; - y0 = y1 - h; - } - 3..=5 => { - x1 = x0 + w; - y1 = y0 + h; - } - 6 | 7 => { - x0 = x1 - w; - y1 = y0 + h; - } - _ => { - x0 = x1 - w; - y0 = y1 - h; - } - } + let (x_edges, y_edges) = resize_edge_flags(handle); + let span = |edges: [bool; 3], lo: f64, hi: f64, mid: f64, len: f64| match edges { + [true, _, _] => (hi - len, hi), + [_, _, true] => (lo, lo + len), + _ => (mid - len / 2.0, mid + len / 2.0), + }; + let c = from.center(); + let (x0, x1) = span(x_edges, rect.x0, rect.x1, c.x, from.width() * s); + let (y0, y1) = span(y_edges, rect.y0, rect.y1, c.y, from.height() * s); Rect::new(x0, y0, x1, y1) } @@ -285,8 +277,7 @@ fn finish_snapped_resize( if !(w.is_finite() && h.is_finite()) { return proposed; } - let c = from.center(); - Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0) + centered_rect(from, w, h) } /// Length match: add the difference once. Position match: the flagged edge moves and the other side mirrors. @@ -313,16 +304,18 @@ fn axis_size_delta(edges: [bool; 3], delta: f64) -> f64 { } } +/// Mirrors the dragged sides around the centre of `from`. Shrinking and crossing the centre follow the pointer. fn apply_alt(from: Rect, handle: usize, rect: Rect) -> Rect { - let (x0, y0, x1, y1) = (rect.x0, rect.y0, rect.x1, rect.y1); + let (x_edges, y_edges) = resize_edge_flags(handle); let c = from.center(); - let (hw, hh) = ((x1 - x0) / 2.0, (y1 - y0) / 2.0); - let (hw, hh) = match handle { - 1 | 5 => (from.width() / 2.0, (if handle == 1 { c.y - y0 } else { y1 - c.y })), - 3 | 7 => ((if handle == 7 { c.x - x0 } else { x1 - c.x }), from.height() / 2.0), - _ => (hw.abs().max((c.x - x0).abs()).max((x1 - c.x).abs()), hh.abs().max((c.y - y0).abs()).max((y1 - c.y).abs())), + let size = |edges: [bool; 3], lo: f64, hi: f64, mid: f64, len: f64| match edges { + [true, _, _] => 2.0 * (mid - lo), + [_, _, true] => 2.0 * (hi - mid), + _ => len, }; - Rect::new(c.x - hw, c.y - hh, c.x + hw, c.y + hh) + let w = size(x_edges, rect.x0, rect.x1, c.x, from.width()); + let h = size(y_edges, rect.y0, rect.y1, c.y, from.height()); + centered_rect(from, w, h) } /// Left/center/right and top/center/bottom for a handle. Centers stay off. diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0da6..f62a400aa 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -60,6 +60,31 @@ fn resize_rect_modifiers() { assert_eq!(c, Rect::new(-50.0, 0.0, 150.0, 50.0)); } +#[test] +fn resize_side_shift_keeps_the_other_axis_centred() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(select::resize_rect(r, 3, Point::new(150.0, 25.0), shift), Rect::new(0.0, -12.5, 150.0, 62.5)); + assert_eq!(select::resize_rect(r, 5, Point::new(50.0, 100.0), shift), Rect::new(-50.0, 0.0, 150.0, 100.0)); +} + +#[test] +fn resize_corner_alt_shift_scales_around_the_centre() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let mods = Mods { shift: true, alt: true, ..Default::default() }; + // From the centre the pointer is at 0.2 by 0.6; the larger factor drives and the corner stays on y = 40. + assert_eq!(select::resize_rect(r, 4, Point::new(60.0, 40.0), mods), Rect::new(20.0, 10.0, 80.0, 40.0)); + assert_eq!(select::resize_rect(r, 0, Point::new(-50.0, 10.0), mods), Rect::new(-50.0, -25.0, 150.0, 75.0)); +} + +#[test] +fn resize_corner_alt_mirrors_the_opposite_side() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let alt = Mods { alt: true, ..Default::default() }; + assert_eq!(select::resize_rect(r, 0, Point::new(10.0, 5.0), alt), Rect::new(10.0, 5.0, 90.0, 45.0)); + assert_eq!(select::resize_rect(r, 4, Point::new(120.0, 60.0), alt), Rect::new(-20.0, -10.0, 120.0, 60.0)); +} + #[test] fn cmd_shift_click_overrides_parent_item() { let mut d = Document::new(&NewDocument::default()); From 2077d9f088dc41bde6b201b098a596ded61fafd7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 14:09:14 +0000 Subject: [PATCH 008/200] UI: show Tamil, Indic, Southeast Asian and other scripts from installed fonts --- crates/ui-egui/src/theme.rs | 135 ++++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 14918008f..c0a2376fb 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -256,7 +256,7 @@ impl Tokens { /// Install the UI fonts for the interface language `lang` (it orders the CJK fallbacks). /// Returns false while the installed fonts are still being cataloged: the built-in faces are /// installed without waiting, and the caller installs again once they are (see -/// [`system_fonts_ready`]) to add the installed CJK faces. +/// [`system_fonts_ready`]) to add the installed faces for the scripts they lack. pub fn install_fonts(ctx: &egui::Context, lang: &str) -> bool { let craft = designcraft_fonts::CRAFT_FONTS; let mut defs = font_definitions(craft, lang); @@ -275,7 +275,7 @@ pub fn system_fonts_ready() -> bool { /// An installed font the UI falls back to for a script the built-in faces lack. pub(crate) struct SystemFont { - /// "Jpan", "Hans", "Hant" or "Kore". + /// The ISO 15924 code of the script it is for ("Jpan", "Kore", "Taml"…). script: &'static str, family: String, /// The face as egui takes it, on the UI face's baseline. Shared: installing the fonts again @@ -290,11 +290,14 @@ impl SystemFont { } } -/// Installed UI faces (sans serif) per script, the first one found of each list: macOS, then -/// Windows, then Linux families. -const SYSTEM_UI_FONTS: &[(&str, &[&str])] = &[ +/// Installed UI faces (sans serif) per script, with a character every face of the script has: +/// the first listed family that is installed and covers the character, macOS families first, +/// then Windows, then Linux. One family can serve several scripts (Nirmala UI covers the Indic +/// scripts); it is added once. +const SYSTEM_UI_FONTS: &[(&str, char, &[&str])] = &[ ( "Jpan", + 'あ', &[ "Hiragino Sans", "Hiragino Kaku Gothic ProN", @@ -309,16 +312,36 @@ const SYSTEM_UI_FONTS: &[(&str, &[&str])] = &[ "Droid Sans Fallback", ], ), - ("Hans", &["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]), + ("Hans", '中', &["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]), // Traditional Chinese and Korean: font menus list those families under their native names. - ("Hant", &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Source Han Sans TC"]), - ("Kore", &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "UnDotum"]), + ("Hant", '中', &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Source Han Sans TC"]), + ("Kore", '한', &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "UnDotum"]), + // Scripts the built-in faces lack, after the CJK ones (text in the Story Editor, font names). + ("Taml", 'த', &["Tamil Sangam MN", "Tamil MN", "Nirmala UI", "Latha", "Noto Sans Tamil", "Noto Sans Tamil UI", "Lohit Tamil"]), + ("Deva", 'क', &["Kohinoor Devanagari", "Devanagari Sangam MN", "Nirmala UI", "Mangal", "Noto Sans Devanagari", "Lohit Devanagari"]), + ("Beng", 'ক', &["Kohinoor Bangla", "Bangla Sangam MN", "Nirmala UI", "Vrinda", "Noto Sans Bengali", "Lohit Bengali"]), + ("Gujr", 'ક', &["Kohinoor Gujarati", "Gujarati Sangam MN", "Nirmala UI", "Shruti", "Noto Sans Gujarati", "Lohit Gujarati"]), + ("Guru", 'ਕ', &["Gurmukhi Sangam MN", "Gurmukhi MN", "Nirmala UI", "Raavi", "Noto Sans Gurmukhi", "Lohit Gurmukhi"]), + ("Knda", 'ಕ', &["Kannada Sangam MN", "Nirmala UI", "Tunga", "Noto Sans Kannada", "Lohit Kannada"]), + ("Mlym", 'ക', &["Malayalam Sangam MN", "Nirmala UI", "Kartika", "Noto Sans Malayalam", "Lohit Malayalam"]), + ("Telu", 'క', &["Kohinoor Telugu", "Telugu Sangam MN", "Nirmala UI", "Gautami", "Noto Sans Telugu", "Lohit Telugu"]), + ("Orya", 'କ', &["Oriya Sangam MN", "Nirmala UI", "Kalinga", "Noto Sans Oriya", "Lohit Odia"]), + ("Sinh", 'ක', &["Sinhala Sangam MN", "Nirmala UI", "Iskoola Pota", "Noto Sans Sinhala", "LKLUG"]), + ("Thai", 'ก', &["Thonburi", "Leelawadee UI", "Tahoma", "Noto Sans Thai", "Loma"]), + ("Khmr", 'ក', &["Khmer Sangam MN", "Leelawadee UI", "Khmer UI", "Noto Sans Khmer", "Khmer OS"]), + ("Laoo", 'ກ', &["Lao Sangam MN", "Leelawadee UI", "Lao UI", "Noto Sans Lao", "Phetsarath OT"]), + ("Mymr", 'က', &["Myanmar Sangam MN", "Myanmar Text", "Noto Sans Myanmar", "Padauk"]), + ("Hebr", 'א', &["Arial Hebrew", "Segoe UI", "Noto Sans Hebrew", "DejaVu Sans"]), + ("Armn", 'Ա', &["Mshtakan", "Segoe UI", "Sylfaen", "Noto Sans Armenian", "DejaVu Sans"]), + ("Geor", 'ა', &["Segoe UI", "Sylfaen", "Noto Sans Georgian", "DejaVu Sans"]), + ("Ethi", 'አ', &["Kefa", "Ebrima", "Nyala", "Noto Sans Ethiopic", "Abyssinica SIL"]), ]; -/// The installed faces for the CJK scripts `craft` (the built-in craft-fonts) doesn't cover, so -/// the interface shows Japanese and Chinese without the craft-fonts build input, and Korean and -/// Traditional Chinese (font names in the font menus) in any build. egui can't -/// find system fonts itself; the document font scan (not on the web) does. +/// The installed faces for the scripts `craft` (the built-in craft-fonts) doesn't cover, so the +/// interface shows Japanese and Chinese without the craft-fonts build input, and Korean, +/// Traditional Chinese (font names in the font menus), Indic, Southeast Asian and other scripts +/// in any build. egui can't find system fonts itself; the document font scan (not on the web) +/// does. fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option<&'static [SystemFont]> { static FACES: std::sync::OnceLock> = std::sync::OnceLock::new(); let db = designcraft_fonts::FontDb::global(); @@ -329,19 +352,30 @@ fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option<&'static [S db.scan_in_background(); return None; } - Some(FACES.get_or_init(|| { - SYSTEM_UI_FONTS - .iter() - .filter(|(script, _)| !craft.iter().any(|f| f.scripts.contains(script))) - .filter_map(|(script, families)| { - let family = families.iter().find(|f| db.has_family(f))?; - let face = db.face(family, "Regular"); - // `face` falls back to another family when this one can't be read: keep only the real one. - (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty()) - .then(|| SystemFont::new(script, (*family).to_string(), face.data().to_vec(), face.index())) - }) - .collect() - })) + Some(FACES.get_or_init(|| installed_ui_fonts(db, craft))) +} + +/// The faces of [`SYSTEM_UI_FONTS`] that `db` has, for the scripts `craft` doesn't cover. +fn installed_ui_fonts(db: &designcraft_fonts::FontDb, craft: &[designcraft_fonts::CraftFont]) -> Vec { + let mut faces: Vec = Vec::new(); + for (script, sample, families) in SYSTEM_UI_FONTS { + if craft.iter().any(|f| f.scripts.contains(script)) { + continue; + } + let found = families.iter().find_map(|family| { + if !db.has_family(family) { + return None; + } + let face = db.face(family, "Regular"); + // `face` falls back to another family when this one can't be read: keep only the real one. + (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty() && face.covers(*sample)).then_some((family, face)) + }); + let Some((family, face)) = found else { continue }; + if !faces.iter().any(|f| f.family.eq_ignore_ascii_case(family)) { + faces.push(SystemFont::new(script, (*family).to_string(), face.data().to_vec(), face.index())); + } + } + faces } /// The UI face every family starts with (Source Sans 3; the semibold shares its line metrics). @@ -376,7 +410,8 @@ fn cjk_script(lang: &str) -> &'static str { /// Add installed faces `system` to every family in the interface language's order: the faces of /// its script (`cjk_script`) go before the built-in CJK faces, so Han characters take that -/// language's forms (Traditional Chinese, Korean); the others go at the end. +/// language's forms (Traditional Chinese, Korean); the other CJK faces go at the end, then the +/// other scripts' faces. pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[SystemFont], lang: &str) { let name = |f: &SystemFont| format!("system-{}", f.family); for f in system { @@ -384,10 +419,12 @@ pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[System } let first = cjk_script(lang); let preferred: Vec = system.iter().filter(|f| f.script == first).map(name).collect(); - let rest: Vec = ["Jpan", "Hans", "Hant", "Kore"] + const CJK: [&str; 4] = ["Jpan", "Hans", "Hant", "Kore"]; + let rest: Vec = CJK .iter() .filter(|s| **s != first) .flat_map(|s| system.iter().filter(move |f| f.script == *s)) + .chain(system.iter().filter(|f| !CJK.contains(&f.script))) .map(name) .collect(); for (family, stack) in fonts.families.iter_mut() { @@ -642,6 +679,50 @@ mod japanese_font_tests { } } + /// Tamil, Devanagari and Thai text get glyphs from installed faces, found by family and + /// checked for coverage, after the CJK faces. Synthetic fonts in a folder of their own stand + /// in for the machine's. + #[test] + fn installed_faces_give_other_scripts_glyphs_after_the_cjk_faces() { + let dir = std::env::temp_dir().join(format!("dc-ui-script-fonts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let fonts = [ + ("Noto Sans CJK JP", &['あ', '日'][..]), + // Listed first for Tamil but without Tamil glyphs: passed over. + ("Tamil Sangam MN", &['a'][..]), + // One face for two scripts is added once. + ("Nirmala UI", &['த', 'க', 'क'][..]), + ("Noto Sans Thai", &['ก'][..]), + ]; + for (i, (family, chars)) in fonts.iter().enumerate() { + std::fs::write(dir.join(format!("font{i}.ttf")), designcraft_fonts::testing::font_with(family, chars).unwrap()).unwrap(); + } + let db = designcraft_fonts::FontDb::with_font_dirs(vec![dir.clone()]); + let system = super::installed_ui_fonts(&db, &[]); + let _ = std::fs::remove_dir_all(&dir); + let picked: Vec<(&str, &str)> = system.iter().map(|f| (f.script, f.family.as_str())).collect(); + assert_eq!(picked, [("Jpan", "Noto Sans CJK JP"), ("Taml", "Nirmala UI"), ("Thai", "Noto Sans Thai")]); + + let mut defs = super::font_definitions(&[], "ja"); + defs.families.entry(egui::FontFamily::Proportional).or_default().push("craft-Stand-in".into()); + super::add_system_fallbacks(&mut defs, &system, "ja"); + let stack = &defs.families[&egui::FontFamily::Proportional]; + let at = |n: &str| stack.iter().position(|x| x == n).unwrap(); + assert!(at("system-Noto Sans CJK JP") < at("craft-Stand-in"), "{stack:?}"); + assert!(at("craft-Stand-in") < at("system-Nirmala UI") && at("system-Nirmala UI") < at("system-Noto Sans Thai"), "{stack:?}"); + defs.families.entry(egui::FontFamily::Proportional).or_default().retain(|n| n != "craft-Stand-in"); + let ctx = egui::Context::default(); + ctx.set_fonts(defs); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + for ch in ['த', 'क', 'ก'] { + assert!(fonts.has_glyph(&font, ch), "missing {ch}"); + } + }); + } + #[test] fn ui_works_without_craft_fonts() { let ctx = ctx_with(&[]); From a02fc011bf5af1300a7e59ecb146487138fe38d1 Mon Sep 17 00:00:00 2001 From: Mara Johnson Date: Sun, 11 Oct 2026 12:41:33 -0400 Subject: [PATCH 009/200] Paragraph panel: Optical Margin Alignment checkbox (#494) The composer already hangs punctuation when a paragraph's opticalMargin is on, but nothing in the UI could turn it on. Reported by @mjohnson7cd --- crates/ui-egui/src/i18n.rs | 4 ++++ crates/ui-egui/src/panels/properties.rs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..6ab27ba56 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2372,6 +2372,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Inward", ["Inward", "Inward", "Inward", "Inward", "向内"]), ("Metrics", ["Metrics", "Metrics", "Metrics", "Metrics", "度量"]), ("Optical", ["Optical", "Optical", "Optical", "Optical", "视觉"]), + ( + "Optical Margin Alignment", + ["Optischer Randausgleich", "Marges optiques", "Alineación de márgenes ópticos", "オプティカルマージン揃え", "视觉边距对齐"], + ), ("Applies to fill", ["Applies to fill", "Applies to fill", "Applies to fill", "Applies to fill", "应用于填色"]), ("Applies to text", ["Applies to text", "Applies to text", "Applies to text", "Applies to text", "应用于文本"]), ("Gray", ["Gray", "Gray", "Gray", "Gray", "灰度"]), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..3d0377558 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1478,6 +1478,10 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "allLines" } else { "none" }}})); } }); + let mut optical = p["opticalMargin"].as_bool().unwrap_or(false); + if ui.checkbox(&mut optical, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Optical Margin Alignment"))).changed() { + let _ = app.run("type.para", json!({"attrs": {"opticalMargin": optical}})); + } ui.horizontal(|ui| { caption(ui, crate::i18n::tr(&app.ui.language, "Composer")); let single = p["composer"].as_str() == Some("singleLine"); From 5f535ef60f87b03dd69a65d9e64e6322e4816331 Mon Sep 17 00:00:00 2001 From: Middle Disc Date: Sun, 11 Oct 2026 21:34:03 +0330 Subject: [PATCH 010/200] 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 7968afa39f88471796771a50bc0e770a1c7ed94e Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:08:18 +0200 Subject: [PATCH 011/200] SVG text: find fonts by PostScript name A font-family that names no family but matches a face's PostScript name (as Illustrator's SVG export writes them, e.g. 'MyriadPro-Regular') now sets the text in that face, from the bundled, loaded or installed fonts. The name picks the face, so font-weight and font-style don't change it. Family names and the generic fallbacks resolve as before. Part of #430 Co-Authored-By: Claude Opus 5.5 --- crates/fonts/src/fontdb.rs | 44 ++++++++++++++++- crates/fonts/src/tests_sysfonts.rs | 20 ++++++++ crates/images/src/lib.rs | 78 ++++++++++++++++++++++++++++-- 3 files changed, 135 insertions(+), 7 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..1a400e9fe 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -121,6 +121,8 @@ pub struct FontFace { em: std::sync::OnceLock<(f64, f64)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option)>, + /// The PostScript name, read on first use. + postscript: std::sync::OnceLock>, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -271,6 +273,13 @@ impl FontFace { let (g, n) = self.group.get_or_init(|| self.skrifa().map(|f| crate::group::classify(&f, &self.family)).unwrap_or_default()); (*g, n.as_deref()) } + /// The PostScript name (`name` ID 6), read from the font once. None for a variable font's + /// named instance: the file's name belongs to its default instance. + pub fn postscript_name(&self) -> Option<&str> { + self.postscript + .get_or_init(|| if self.is_variable() { None } else { self.skrifa().and_then(|f| name(&f, &[StringId::POSTSCRIPT_NAME])) }) + .as_deref() + } /// Glyph id for `c` (0 = .notdef). pub fn glyph_for(&self, c: char) -> u32 { self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) @@ -304,6 +313,7 @@ struct CatalogEntry { /// The font menu group and native family name, read by the scan. group: FontGroup, native: Option, + postscript: Option, } /// What the scan reads of one face in a font file: its names and font menu group. @@ -314,6 +324,7 @@ struct ScannedFace { style: String, group: FontGroup, native: Option, + postscript: Option, } /// Process-wide font database: the fonts every document shares (bundled, installed, added), and @@ -567,7 +578,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 postscript = name(&f, &[StringId::POSTSCRIPT_NAME]); + Some(ScannedFace { family, style, group, native, postscript }) }) .collect() } @@ -709,6 +721,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s bmp: std::sync::OnceLock::new(), em: std::sync::OnceLock::new(), group: std::sync::OnceLock::new(), + postscript: std::sync::OnceLock::new(), }) } @@ -925,7 +938,14 @@ 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, + path: p.clone(), + group: f.group, + native: f.native, + postscript: f.postscript, + }); } } } @@ -1074,6 +1094,11 @@ impl FontDb { self.scoped(0).face(family, style) } + /// The shared face with PostScript name `name` (see [`ScopedFonts::postscript_face`]). + pub fn postscript_face(&self, name: &str) -> Option<(String, String)> { + self.scoped(0).postscript_face(name) + } + /// Is `family` among the shared fonts (loaded, or installed on the system)? pub fn has_family(&self, family: &str) -> bool { self.scoped(0).has_family(family) @@ -1283,6 +1308,21 @@ impl ScopedFonts<'_> { self.has_exact_family(family) || without_format_suffix(family).is_some_and(|base| self.has_exact_family(base)) } + /// The family and style of the face whose PostScript name is `name`, ignoring case (the + /// document's, loaded, or installed on the system, not loading it). SVGs exported from + /// Illustrator name fonts this way (`font-family="'MyriadPro-Regular'"`). + pub fn postscript_face(&self, name: &str) -> Option<(String, String)> { + let named = |ps: Option<&str>| ps.is_some_and(|ps| ps.eq_ignore_ascii_case(name)); + if let Some(f) = self.own().iter().chain(self.db.read_faces().iter()).find(|f| named(f.postscript_name())) { + return Some((f.family.clone(), f.style.clone())); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(c) = self.db.read_catalog().iter().find(|c| named(c.postscript.as_deref())) { + return Some((c.family.clone(), c.style.clone())); + } + None + } + 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) { return true; diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index a9cda7cdc..f10088705 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -164,3 +164,23 @@ fn font_menus_group_installed_and_loaded_fonts_without_loading_them() { assert_eq!(infos.iter().filter(|i| i.family == "Groups Mincho").count(), 1); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn installed_and_loaded_fonts_are_found_by_postscript_name() { + let dir = std::env::temp_dir().join(format!("dc-sysfonts-postscript-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("ps.ttf"), crate::testing::font_with("DC Test PS Installed", &['a']).unwrap()).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + let found = Some(("DC Test PS Installed".to_string(), "Regular".to_string())); + // From the catalog, without loading the font; case doesn't matter. + assert_eq!(db.postscript_face("dctestpsinstalled-regular"), found); + assert!(!db.is_loaded("DC Test PS Installed")); + // Bundled and added (loaded) faces. + assert_eq!(db.postscript_face("SourceSerif4-Semibold"), Some(("Source Serif 4".to_string(), "Semibold".to_string()))); + db.add_font(crate::testing::font_with("DC Test PS Added", &['a']).unwrap()); + assert_eq!(db.postscript_face("DCTestPSAdded-Regular"), Some(("DC Test PS Added".to_string(), "Regular".to_string()))); + // A family name isn't a PostScript name. + assert_eq!(db.postscript_face("Source Serif 4"), None); + assert_eq!(db.postscript_face("NoSuchFont-Regular"), None); +} diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 4751263a4..4f507f486 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -73,25 +73,54 @@ fn svg_options() -> &'static usvg::Options<'static> { } /// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too), -/// as it would in a text frame. +/// as it would in a text frame. A name that is no family's but a face's PostScript name (as +/// Illustrator's SVG export writes them: `font-family="'MyriadPro-Regular'"`) sets the text in +/// that face, whatever font-weight and font-style say: the name already picks the weight and +/// style, and a family's other faces would contradict it. Families listed before it come first. fn select_font() -> usvg::FontSelectionFn<'static> { let bundled = usvg::FontResolver::default_font_selector(); Box::new(move |font, db| { let fonts = designcraft_fonts::FontDb::global(); for family in font.families() { if let usvg::FontFamily::Named(name) = family - && !db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))) - && fonts.has_family(name) + && !has_family(db, name) { - for style in fonts.styles(name) { - add_face(db, fonts.face(name, &style)); + if fonts.has_family(name) { + for style in fonts.styles(name) { + add_face(db, fonts.face(name, &style)); + } + } else if postscript_face(db, name).is_none() + && let Some((family, style)) = fonts.postscript_face(name) + { + add_face(db, fonts.face(&family, &style)); } } } + for family in font.families() { + match family { + usvg::FontFamily::Named(name) if !has_family(db, name) => { + if let Some(id) = postscript_face(db, name) { + return Some(id); + } + } + // A family (or generic family) that matches: usvg's own selection. + _ => break, + } + } bundled(font, db) }) } +/// Does a face in the SVG's font database have family `name` (ignoring case)? +fn has_family(db: &usvg::fontdb::Database, name: &str) -> bool { + db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))) +} + +/// The face in the SVG's font database with PostScript name `name` (ignoring case). +fn postscript_face(db: &usvg::fontdb::Database, name: &str) -> Option { + db.faces().find(|f| f.post_script_name.eq_ignore_ascii_case(name)).map(|f| f.id) +} + /// Characters the chosen fonts lack (Korean, Arabic…) fall back to a shared face that has them. fn select_fallback() -> usvg::FallbackSelectionFn<'static> { let loaded = usvg::FontResolver::default_fallback_selector(); @@ -342,6 +371,45 @@ mod tests { assert!(set.len() == 2 && set.iter().all(|(f, gid)| f != "Source Sans 3" && *gid != 0), "{set:?}"); } + /// The PostScript name of the face that sets the text of `svg` (one face for all of it). + fn text_face(svg_attrs: &str) -> String { + let svg = + format!(r#"ab"#); + let tree = svg_tree(svg.as_bytes()).unwrap(); + let mut faces = Vec::new(); + for n in tree.root().children() { + if let usvg::Node::Text(t) = n { + for g in t.layouted().iter().flat_map(|s| &s.positioned_glyphs) { + faces.extend(tree.fontdb().face(g.font).map(|f| f.post_script_name.clone())); + } + } + } + faces.dedup(); + assert_eq!(faces.len(), 1, "{faces:?}"); + faces.remove(0) + } + + #[test] + fn svg_text_finds_fonts_by_postscript_name() { + // Bundled, as Illustrator's SVG export names it; any case. + assert_eq!(text_face(r#" font-family="'SourceSerif4-Semibold'""#), "SourceSerif4-Semibold"); + assert_eq!(text_face(r#" font-family="sourceserif4-semibold""#), "SourceSerif4-Semibold"); + // A shared font (as an installed one would be). + let font = designcraft_fonts::testing::font_with("DC Test SVG PostScript", &['a', 'b']).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + assert_eq!(text_face(r#" font-family="DCTestSVGPostScript-Regular""#), "DCTestSVGPostScript-Regular"); + // The name picks that face: font-weight and font-style don't change it. + assert_eq!(text_face(r#" font-family="SourceSerif4-Semibold" font-weight="bold" font-style="italic""#), "SourceSerif4-Semibold"); + // A family listed before it still comes first; one after it is a fallback. + assert_eq!(text_face(r#" font-family="'Source Sans 3', SourceSerif4-Bold""#), "SourceSans3-Regular"); + assert_eq!(text_face(r#" font-family="NoSuch-Font, SourceSerif4-Bold, 'Source Sans 3'""#), "SourceSerif4-Bold"); + // Family names work as before. + assert_eq!(text_face(r#" font-family="'Source Serif 4'" font-weight="bold""#), "SourceSerif4-Bold"); + // An unknown name falls back as before (the serif default). + assert_eq!(text_face(r#" font-family="NoSuch-Font""#), text_face(r#" font-family="No Such Font""#)); + assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular"); + } + /// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image. fn tiny_psd() -> Vec { let mut b = Vec::new(); From 406f1b3d267fa17617a96d75324d0d709a875361 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 05:09:40 +0000 Subject: [PATCH 012/200] Sections: the section marker shows the section's marker text (#556) --- ROADMAP.md | 1 + crates/compose/src/cache.rs | 12 +++++++++-- crates/compose/src/lib.rs | 13 +++++++++++- crates/compose/src/notes.rs | 2 ++ crates/compose/src/shape.rs | 10 +++++++++- crates/compose/src/table.rs | 1 + crates/engine/src/cmd/variables.rs | 32 ++++++++++++++++++++++++++++++ 7 files changed, 67 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d33353caa..e4cb88230 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-12** · The section marker character shows its section's marker text on document pages (nothing when the section has none) instead of the word "Section", which stays on parent pages; editing the marker recomposes (#556). 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/compose/src/cache.rs b/crates/compose/src/cache.rs index 8aabbf0f1..4ede03594 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -67,10 +67,18 @@ impl Cache { // The key holds the page's index, which a renumbering doesn't change: its name (section // start number, style, prefix) is part of the signature. let page_name = page.map(|p| doc.page_name(p)); + let section_marker = page.map(|p| crate::section_marker(doc, p)); if let Some(name) = &page_name { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - name.hash(&mut h); + (name, §ion_marker).hash(&mut h); + sig.push(h.finish() as usize); + } + if story.text.contains(designcraft_doc::story::SECTION_MARKER) { + // Frames on document pages show their section's marker text. + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + doc.sections.iter().for_each(|s| (s.start, &s.marker).hash(&mut h)); sig.push(h.finish() as usize); } let with_vars = crate::vars::has_vars(story); @@ -144,7 +152,7 @@ impl Cache { let out = Arc::new(crate::compose_story( doc, sid, - &ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, + &ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, )); let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); let stamp = g.1; diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..7db6bd27a 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -303,6 +303,8 @@ pub struct FrameSpec { pub vertical: bool, pub exclusions: Vec, pub page_name: Option, + /// Marker text of the section the frame's page is in (None on parent pages). + pub section_marker: Option, /// Absolute document page the frame is on (None on parent pages). pub page: Option, /// Baseline grid in inner space: (first grid line y, increment). @@ -329,11 +331,18 @@ impl FrameSpec { } } +/// The marker text of the section absolute page `abs` is in (empty when it has none). +pub fn section_marker(doc: &Document, abs: usize) -> String { + doc.section_of(abs).map(|s| s.marker.clone()).unwrap_or_default() +} + /// Composition inputs that are not in the story itself. #[derive(Clone, Debug, Default)] pub struct ComposeOptions { /// Page name for page-number markers when the story is composed for a specific page (parent items). pub page_name: Option, + /// Section marker text when the story is composed for a specific page (parent items). + pub section_marker: Option, /// Absolute page for text variables when composed for a specific page (parent items). pub page: Option, /// Running-header index (needed when the story contains running-header variables). @@ -387,6 +396,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { } _ => (None, None, false), }; + let section_marker = page.map(|abs| section_marker(doc, abs)); // The page under the frame and its margins, in inner space (bounding boxes). let page_rect = match (loc.spread, spread) { (_, Some(sp)) => sp @@ -438,6 +448,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { vertical, exclusions: if tf.options.path.is_some() { vec![] } else { exclusions }, page_name, + section_marker, page, grid, left_page, @@ -618,7 +629,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; let mut sub = SubstCtx { page_name: opts.page_name.clone().or_else(|| cur_frame.and_then(|f| f.page_name.clone())), - section_marker: None, + section_marker: opts.section_marker.clone().or_else(|| cur_frame.and_then(|f| f.section_marker.clone())), vars: var_values, hidden_conditions: doc.conditions.iter().filter(|c| !c.visible).map(|c| c.name.clone()).collect(), vertical: cur_frame.is_some_and(|f| f.vertical), diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 32b2bbec2..d0055ee06 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -102,6 +102,7 @@ impl Notes { vertical: false, exclusions: vec![], page_name: f.page_name.clone(), + section_marker: f.section_marker.clone(), page: f.page, grid: None, left_page: f.left_page, @@ -109,6 +110,7 @@ impl Notes { }; let nopts = ComposeOptions { page_name: opts.page_name.clone(), + section_marker: opts.section_marker.clone(), page: opts.page, running: opts.running.clone(), label: Some(label), diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 1e1af40a7..0d00d3275 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -88,6 +88,7 @@ pub struct ShapedPara { #[derive(Clone, Debug, Default)] pub struct SubstCtx { pub page_name: Option, + /// The section marker text of the page being composed (`None` on parent pages: "Section"). pub section_marker: Option, /// Text variable values by index (on the page being composed). pub vars: Arc>, @@ -586,7 +587,14 @@ fn shape_run_raw( } else { sub.page_name.clone().unwrap_or_else(|| "#".into()) }; - shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out, sub.vertical); + if s.is_empty() { + // A section without marker text: the character takes no space. + let mut g = control_glyph(&primary, p, auto_leading, style, i, c); + g.adv = 0.0; + out.push(g); + } else { + shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out, sub.vertical); + } } designcraft_doc::OBJECT_MARK => { let mut g = control_glyph(&primary, p, auto_leading, style, i, c); diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index c70f477be..bc4f0d06f 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -112,6 +112,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left vertical: false, exclusions: vec![], page_name: opts.page_name.clone(), + section_marker: opts.section_marker.clone(), page: opts.page, grid: None, left_page, diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index e7772d4ea..3288e8161 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -153,6 +153,38 @@ mod tests { assert_eq!(shown(&s, 2), 3, "page 102 after renumbering"); } + /// A section marker character shows the marker text of the section of the page it is composed + /// for: on each document page a parent-page frame is shown on, and in a frame on a document + /// page. A section without marker text shows nothing; the parent page itself shows "Section". + #[test] + fn section_marker_shows_the_pages_section_marker() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1, "marker": "Intro"})).unwrap(); + let marker = designcraft_doc::story::SECTION_MARKER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let parent = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let sp = s.doc().unwrap().doc.page_loc(3).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": marker})).unwrap(); + let own = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let shown = |s: &Session, sid, page: Option| -> usize { + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, page); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(&s, parent, Some(0)), 0, "the first section has no marker"); + assert_eq!(shown(&s, parent, Some(2)), "Intro".len()); + assert_eq!(shown(&s, parent, Some(3)), "Intro".len()); + assert_eq!(shown(&s, own, None), "Intro".len(), "a frame on a document page"); + assert_eq!(shown(&s, parent, None), "Section".len(), "the parent page itself"); + // Editing the marker recomposes. + s.execute("layout.section", &json!({"page": 3, "marker": "Appendix"})).unwrap(); + assert_eq!(shown(&s, parent, Some(2)), "Appendix".len()); + assert_eq!(shown(&s, own, None), "Appendix".len()); + } + /// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running /// header is composed for the page it is shown on, not the first page with that name. #[test] From 0635dea06548ceb975922b511db0215d5144eecd Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Mon, 12 Oct 2026 06:16:04 +0000 Subject: [PATCH 013/200] Text wrap: small single-character frames (tombstones) are set beside text wrap An article's closing tombstone - a one-character text frame, here a 7.6 pt Noto Emoji symbol in a 10.5 pt box - was overset (red +, nothing drawn) whenever its spread had a text-wrap object anywhere, while InDesign sets it. Two causes in the wrap code: - Every text frame took every wrap object on its spread as an exclusion, whether or not the object reached the frame. Now only objects that overlap the frame wrap its text. - free_slot skips gaps narrower than 1.5 em of the paragraph's size (slivers beside a wrapped object), but applied that to the whole column too when nothing cut it: with a 14 pt paragraph style, no line could go in a frame narrower than 21 pt. The minimum now only applies to gaps a wrap object cut; a band no object reaches gets the whole column, however narrow. On the document that showed it (an InDesign 2026 IDML with 12 tombstones and two unrelated wrap objects on the spread), the 12 tombstone stories are set and no other story's layout changes (54 -> 42 overset stories, all of the difference the tombstones). Tests: small_single_character_frame_sets_beside_unrelated_text_wrap and narrow_frame_sets_lines_no_wrap_object_reaches fail before (overset) and pass after. Probably also #476 (Wingdings bullets overset in 20 x 21 pt frames). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/compose/src/lib.rs | 17 ++++++++++++++++- crates/compose/src/tests.rs | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..5ab809649 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -370,6 +370,13 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { let b = other.bounds(); let r = Rect::new(b.x0 - o[1], b.y0 - o[0], b.x1 + o[3], b.y1 + o[2]); let inner = inv.transform_rect_bbox(r); + // Only objects that reach the frame wrap its text: one elsewhere on the spread + // leaves it alone (a small frame beside it would otherwise lose its gaps to the + // minimum gap width, see `free_slot`). + let reach = inner.intersect(item.inner_bounds()); + if !(reach.width() > 0.0 && reach.height() > 0.0) { + continue; + } exclusions.push(Exclusion { rect: inner, mode: other.wrap.mode }); } } @@ -2071,9 +2078,13 @@ fn line_metrics( (asc, desc, lead) } -/// Widest free horizontal interval of `col` in the band, or None if blocked. +/// Widest free horizontal interval of `col` in the band, or None if blocked. A gap a wrap object +/// cuts narrower than 1.5 em of `size` is skipped (a sliver beside the object); when no object +/// reaches the band the whole column is free, however narrow (a one-character frame such as an +/// article's closing tombstone). fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> { let mut free = vec![(col.x0, col.x1)]; + let mut cut = false; for ex in &f.exclusions { let r = ex.rect; let overlaps_band = r.y0 < y1 && r.y1 > y0; @@ -2081,6 +2092,7 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => { + cut = true; let mut next = Vec::new(); for (a, b) in free { if r.x1 <= a || r.x0 >= b { @@ -2099,6 +2111,9 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f _ => {} } } + if !cut { + return Some((col.x0, col.x1)); + } free.into_iter().filter(|(a, b)| b - a >= size * 1.5).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0))) } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..b0cc474c7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -146,6 +146,44 @@ fn wrap_pushes_text_aside() { assert!(below.x0 < 1.0); } +/// A small frame holding one character (a tombstone at the end of an article: a 7.6 pt symbol in +/// a 10.5 pt box), in a paragraph whose style is 14 pt, with a text-wrap object on the spread at +/// `wrap`. +fn tombstone(frame: Rect, wrap: Rect) -> (Document, StoryId) { + let (mut d, sid, _) = doc_with("X", frame, ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.paras[0].chars = designcraft_doc::CharAttrs { size: Some(14.0), ..Default::default() }; + st.format_chars(0..1, |f| f.over.size = Some(7.6)); + let lid = d.default_layer(); + let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(wrap)); + it.wrap.mode = WrapMode::BoundingBox; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + (d, sid) +} + +/// A text-wrap object elsewhere on the spread doesn't affect a frame it doesn't overlap: the small +/// frame's one character is set (it was overset: every frame took every wrap object on the +/// spread, and then no gap narrower than 1.5 em of the paragraph's size was used). +#[test] +fn small_single_character_frame_sets_beside_unrelated_text_wrap() { + let (d, sid) = tombstone(Rect::new(300.0, 300.0, 310.5, 310.5), Rect::new(0.0, 0.0, 120.0, 60.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "the tombstone is set"); + assert_eq!(cs.line_count(), 1); +} + +/// A narrow frame that a text-wrap object overlaps further down sets the lines the object doesn't +/// reach: the minimum gap width only applies to gaps a wrap object cuts. +#[test] +fn narrow_frame_sets_lines_no_wrap_object_reaches() { + let (d, sid) = tombstone(Rect::new(300.0, 300.0, 310.5, 340.0), Rect::new(290.0, 330.0, 330.0, 360.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "the first line is above the wrap object"); + assert_eq!(cs.line_count(), 1); + let (top, baseline) = (cs.frames[0].columns[0].y0, cs.frames[0].lines[0].baseline); + assert!(baseline - top < 20.0, "on the first line: {baseline} (text area from {top})"); +} + #[test] fn paragraph_composer_is_no_worse_than_greedy() { // Sum of squared slack over lines (excluding last) should not exceed the greedy result. From 591cb579b3bfcafd2e212ed9d7f3cce8581347c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 11 Oct 2026 23:14:31 -0700 Subject: [PATCH 014/200] Swatches: Load Swatches refuses .ase files over 100,000 colors and merges in linear time ase::read accepted any number of colors in a swatch exchange file, and swatch.load looked up each incoming name with a linear search through the document's swatches. The time to load a file grew with the square of its number of colors: in a test build, 50,000 colors used 2.5 s of CPU and 100,000 used 9.6 s. No limit applied to larger files. ase::read now returns an error for a file with more than 100,000 colors (ase::MAX_COLORS). load_swatches looks up each name's first swatch in a HashMap, and each color added from the file is indexed too. A file of 100,000 colors now loads in under 0.1 s. The merge follows the same rules as before: names already in the document are kept unless replace is set, a name repeated in the file matches its first copy, and a locked swatch is left unchanged. --- ROADMAP.md | 1 + crates/color/src/ase.rs | 25 ++++++++++++++++++++- crates/engine/src/cmd/style.rs | 41 +++++++++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d33353caa..f4deeef4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **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-12** · Load Swatches refuses an `.ase` file with more than 100,000 colors, and `swatch.load` merges a file of 100,000 colors in under 0.1 s (it took 9.6 s of CPU in a test build). No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. - **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. diff --git a/crates/color/src/ase.rs b/crates/color/src/ase.rs index ed592bf58..ecae906fd 100644 --- a/crates/color/src/ase.rs +++ b/crates/color/src/ase.rs @@ -11,8 +11,12 @@ pub enum AseError { Signature, #[error("the file ends early")] Truncated, + #[error("the file holds more than {MAX_COLORS} colors")] + TooManyColors, } +/// The most colors [`read`] accepts in one file. +pub const MAX_COLORS: usize = 100_000; const COLOR: u16 = 0x0001; #[cfg(test)] const GROUP_START: u16 = 0xC001; @@ -56,6 +60,7 @@ impl R<'_> { } /// The colour swatches in an `.ase` file (groups are flattened). +/// A file with more than [`MAX_COLORS`] colors is an error. pub fn read(bytes: &[u8]) -> Result, AseError> { let mut r = R { b: bytes, i: 0 }; if r.take(4)? != b"ASEF" { @@ -63,7 +68,7 @@ pub fn read(bytes: &[u8]) -> Result, AseError> { } r.take(4)?; // version let blocks = r.u32()?; - let mut out = Vec::new(); + let (mut out, mut colors) = (Vec::new(), 0usize); for _ in 0..blocks { let kind = r.u16()?; let len = r.u32()? as usize; @@ -72,6 +77,10 @@ pub fn read(bytes: &[u8]) -> Result, AseError> { if kind != COLOR { continue; } + colors += 1; + if colors > MAX_COLORS { + return Err(AseError::TooManyColors); + } let mut b = R { b: body, i: 0 }; let name = b.name()?; let model = b.take(4)?.to_vec(); @@ -191,4 +200,18 @@ mod tests { let SwatchValue::Color { color, .. } = &w[0].value else { panic!() }; assert!(color.to_rgb().iter().all(|v| *v > 0.98), "Lab white: {color:?}"); } + + #[test] + fn more_than_max_colors_is_an_error() { + let gray = Swatch { + name: "G".into(), + value: SwatchValue::Color { color: Color::Gray { k: 0.5 }, color_type: ColorType::Process }, + locked: false, + named: true, + hidden: false, + }; + let sw = vec![gray; MAX_COLORS + 1]; + 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"); + } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 8a520b685..69e58b24d 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -815,9 +815,14 @@ fn load_swatches(s: &mut Session, p: &Value) -> Result { let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?; let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); s.edit(|d, _| { + // The position of the first swatch with each name, kept up to date as the file's colors are added. + let mut at: std::collections::HashMap = std::collections::HashMap::new(); + for (i, x) in d.swatches.iter().enumerate() { + at.entry(x.name.clone()).or_insert(i); + } let (mut added, mut replaced) = (0, 0); for w in incoming { - match d.swatches.iter_mut().find(|x| x.name == w.name) { + match at.get(&w.name).and_then(|&i| d.swatches.get_mut(i)) { Some(x) if x.locked => {} Some(x) => { if replace { @@ -827,6 +832,7 @@ fn load_swatches(s: &mut Session, p: &Value) -> Result { } } None => { + at.insert(w.name.clone(), d.swatches.len()); d.swatches.push(w); added += 1; } @@ -1314,6 +1320,39 @@ mod color_tests { assert!(s.execute("swatch.load", &json!({"base64": "AAAA"})).is_err()); } + #[test] + fn loading_swatches_matches_the_first_swatch_of_each_name() { + use designcraft_color::{Color, ColorType}; + let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; + let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Two swatches share a name, and an unnamed color is hidden. + s.edit(|d, _| { + d.swatches.extend([sw("Twin", 0.1), sw("Twin", 0.2), Swatch { named: false, hidden: true, ..sw("Unnamed", 0.3) }]); + Ok(()) + }) + .unwrap(); + let file = designcraft_color::ase::write(&[sw("New", 0.4), sw("New", 0.5), sw("Twin", 0.6), sw("Unnamed", 0.7), sw("[Black]", 0.8)]); + let b64 = crate::cmd::base64_encode(&file); + let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { + s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.hidden)).collect() + }; + // With `replace`, the file's second "New" replaces its first, only the first "Twin" changes, the hidden + // swatch becomes visible, and the locked [Black] stays. + let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(1), Some(3))); + assert_eq!(named(&s, "New"), [(ink(0.5), false)]); + assert_eq!(named(&s, "Twin"), [(ink(0.6), false), (ink(0.2), false)]); + assert_eq!(named(&s, "Unnamed"), [(ink(0.7), false)]); + assert_eq!(named(&s, "[Black]"), [(ink(1.0), false)]); + // Without it, every name is already there and nothing changes. + let before = s.doc().unwrap().doc.swatches.clone(); + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); + assert_eq!(s.doc().unwrap().doc.swatches, before); + } + #[test] fn unnamed_colors_then_add_to_swatches() { let mut s = Session::new(); From c310cf72ed534ffd31b7d3164fbd8039056bff7b Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Mon, 12 Oct 2026 12:09:40 +0530 Subject: [PATCH 015/200] fix(compose): enable Norwegian hyphenation patterns Enable hypher/norwegian in designcraft-compose so no/nb/nn map to the bundled hyph-no.tex patterns. Add unit + composition regression tests, NOTICE attribution, and update parity docs. Fixes #535. --- NOTICE | 6 ++++++ crates/compose/Cargo.toml | 2 +- crates/compose/src/hyphen/mod.rs | 23 ++++++++++++++++++++++- crates/compose/src/tests.rs | 19 +++++++++++++++++++ docs/gaps.md | 2 +- docs/localization-parity.md | 4 ++-- docs/target-app-parity.md | 4 ++-- docs/typography-parity.md | 2 +- 8 files changed, 54 insertions(+), 8 deletions(-) diff --git a/NOTICE b/NOTICE index 087bb6196..972ab4567 100644 --- a/NOTICE +++ b/NOTICE @@ -52,6 +52,12 @@ list (author, source, license) is in ASSETS.md. INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +- Norwegian hyphenation patterns, embedded through the hypher crate (feature "norwegian"): + hyph-no.tex from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) Rune Kleveland and Ole Michael Selberg. Copying and distribution of this file, + with or without modification, is permitted in any medium without royalty provided the copyright + notice and this notice are preserved (additionally distributed under the LaTeX Project Public + License in CTAN distributions). - Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho, diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index b8993bcfb..de678b37f 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -18,7 +18,7 @@ designcraft-fonts = { workspace = true } log = { workspace = true } regex = { workspace = true } miniz_oxide = "0.8" -hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish"] } +hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish", "norwegian"] } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/compose/src/hyphen/mod.rs b/crates/compose/src/hyphen/mod.rs index e3f4a43b3..ae5997e52 100644 --- a/crates/compose/src/hyphen/mod.rs +++ b/crates/compose/src/hyphen/mod.rs @@ -1,7 +1,8 @@ //! Hyphenation. English uses the bundled dictionary and patterns below. Other languages use the //! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's //! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J. -//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause (see NOTICE). +//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause; Norwegian is +//! `hyph-no.tex` from hyph-utf8 (Rune Kleveland, Ole Michael Selberg), permissive (see NOTICE). //! //! English lookup order for each word: //! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list @@ -411,6 +412,26 @@ mod tests { assert_eq!(h("pós-graduação"), "pós-gra-du-a-ção", "each part of a compound"); } + #[test] + fn norwegian_uses_the_hyph_no_patterns() { + let nb = Lang::for_language("Norwegian: Bokmål").unwrap(); + assert_eq!(nb, Lang::for_language("Norwegian").unwrap()); + assert_eq!(nb, Lang::for_language("Norwegian: Nynorsk").unwrap()); + assert_eq!(nb, Lang::for_language("nb").unwrap()); + assert_eq!(nb, Lang::for_language("nn").unwrap()); + assert_eq!(nb, Lang::for_language("no").unwrap()); + assert_ne!(nb, Lang::for_language("Spanish").unwrap()); + let h = |w: &str| { + let pts = hyphen_points_in(w, &Limits::default(), nb); + w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::() + }; + assert_eq!(h("kaller"), "kal-ler"); + assert_eq!(h("bilde"), "bil-de"); + assert_eq!(h("sjøfugl"), "sjø-fugl"); + assert_eq!(h("sirkulasjonen"), "sir-ku-la-sjo-nen"); + assert_eq!(h("beveger"), "be-ve-ger"); + } + #[test] fn heuristic_fallback() { let c: Vec = "happen".chars().collect(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..aeecc5144 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1388,6 +1388,25 @@ fn portuguese_text_gets_portuguese_hyphenation() { } } +#[test] +fn norwegian_text_gets_norwegian_hyphenation() { + let text = "sirkulasjonen beveger nedsynkningen kaller overflaten bilde"; + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 76.0, 700.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| f.over.language = Some("Norwegian: Bokmål".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let breaks: Vec<&str> = all_lines(&cs).into_iter().filter(|l| l.hyphenated).map(|l| text[l.range.clone()].trim_end()).collect(); + assert!(!breaks.is_empty(), "Norwegian text is hyphenated"); + for b in breaks { + let tail = b.rsplit(' ').next().unwrap_or(b); + let whole = text[text.find(b).unwrap() + b.len() - tail.len()..].split(|c: char| !c.is_alphabetic()).next().unwrap_or(""); + let nb = hyphen::Lang::for_language("Norwegian: Bokmål").unwrap(); + let pts = hyphen::hyphen_points_in(whole, &hyphen::Limits::default(), nb); + assert!(pts.contains(&tail.chars().count()), "break {tail}- in {whole} is a hyph-no point"); + } +} + #[test] fn cjk_aki_adds_space_without_scaling_outlines() { let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); diff --git a/docs/gaps.md b/docs/gaps.md index 6f92c5500..b04e6691a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -135,7 +135,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 25–40 h. ### 13. Hyphenation and spelling languages (T, L) -- **Missing:** hyphenation patterns for every language but English and Spanish; spelling for +- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese and Norwegian; spelling for every language but English. InDesign ships dictionaries for ~40 languages. - **Estimate:** 30–60 h (permissively licensed patterns exist for most; spelling word lists need licence review). **Doc:** [typography-parity.md](typography-parity.md) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d36053202..8b630a06c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -33,7 +33,7 @@ come as Middle East editions with an English or French interface and right-to-le | Hindi | hi | ✗ | 0 (0%) | none | Devanagari shaped by harfrust in documents but untested; no Hindi hyphenation or spelling | no | none | 20–30 | | Arabic | ar | Middle East edition (English/French UI) | 1,459 (85%) | menus, panels, most dialogs; no help | RTL UI text (`rtl.rs`: bidi + shaping); Arabic composition, kashida, digits, RTL stories and tables ✓ (arabic-typography.md); list bullets and multi-column RTL open (#100) | no | partial | 15–25 | | French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no French hyphenation | no | partial | 6–10 | -| Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; no Portuguese hyphenation | no | partial | 8–12 | +| Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; Portuguese hyphenation ✓ | no | partial | 8–12 | | Indonesian | id | ✗ | 0 (0%) | none | Latin ✓ | no | none | 12–18 | | Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); **no canvas IME** (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME, shared with zh) | | German | de | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no German hyphenation (InDesign ships 2006 reform patterns) | no | partial | 6–10 | @@ -56,7 +56,7 @@ eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, ## Document languages InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries). -DesignCraft hyphenates **English and Spanish** and spell-checks **English** only. Text language +DesignCraft hyphenates **English, Spanish, Portuguese and Norwegian** and spell-checks **English** only. Text language can be set per paragraph and character (Properties, Character panel) and drives quotes and locale shaping. See [typography-parity.md](typography-parity.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9c27a9f8..4b0b4a6e3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -59,7 +59,7 @@ they receive, setting type exactly, and basic editing that never fails. | Dimension | Weight | Ready | Remaining (Opus 5.5 h) | Evidence | Doc | |---|---|---|---|---|---| | Features (depth of each area) | 25% | 55% | 250–400 | 275 catalogue rows exist; many lack options (e.g. Books is 258 lines of engine code, Liquid Layout 329); see the area table | this file | -| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English and Spanish only | [typography-parity.md](typography-parity.md) | +| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English, Spanish, Portuguese and Norwegian only | [typography-parity.md](typography-parity.md) | | File formats | 20% | 40% | 150–250 (+20–60 for INDD integration) | INDD cannot be opened (#21); IDML both ways and verified in InDesign 2026, but real-file bugs open (#193, #338, #339, #340); PDF export strong; PDF/X-4 only | [file-format-parity.md](file-format-parity.md) | | UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165), canvas IME (#322) failing | [ui-parity.md](ui-parity.md) | | Stability | 10% | 45% | 60–100 | Never-crash rules and last-resort guards; startup failures on some GPUs and machines (#334, #273, #220, #192, #167, #166); no CI runs the tests on pull requests | [gaps.md](gaps.md) | @@ -346,7 +346,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. -- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; hyphenation for other languages isn't bundled yet. +- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause) and Norwegian with hyph-utf8's `hyph-no.tex` (permissive), all through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 0339faf1e..e86167592 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated. | Single-line Composer | greedy | greedy with the same ranges | ✓ | | | World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested | | Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab now justifies after the tab (#284) | -| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list) and Spanish (Bezos); zone, limits, exceptions in the user dictionary | ~ | other languages don't hyphenate (#87 reported "doesn't work") | +| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt) and Norwegian (hyph-no); zone, limits, exceptions in the user dictionary | ~ | other languages don't hyphenate (#87 reported "doesn't work") | | Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | | Kerning | Metrics, Optical, manual, none | Metrics, manual, none | ~ | **Optical treated as Metrics** (#181): imported display text sets wider and can overset | | Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | From 45f2a2090ba37db7601452e9e2ce0b60b21b10e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 12 Oct 2026 00:53:01 -0700 Subject: [PATCH 016/200] Contributors: add dotCooCoo (Robert Lee) --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 67a8fdf30..16cc84160 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,10 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas" From 152086b3ea1e06efd716b5676a52ed52f7284524 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 12:28:06 +0000 Subject: [PATCH 017/200] Footnotes: notes stack baseline to baseline (#582) --- crates/compose/src/notes.rs | 83 +++++++++++++++++++++++++++---------- crates/compose/src/tests.rs | 46 ++++++++++++++++++++ 2 files changed, 108 insertions(+), 21 deletions(-) diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 32b2bbec2..e2614adab 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -5,6 +5,11 @@ //! 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. +//! +//! The footnotes of a column read as one text: the first footnote's first baseline sits at the +//! First Baseline offset below the top of the footnote area, and each later footnote's first +//! baseline sits its first line's leading (plus Space Between Footnotes) below the previous +//! footnote's last baseline. Only the last footnote's descent counts toward the area's height. use std::collections::HashMap; use std::ops::Range; @@ -22,10 +27,38 @@ pub(crate) struct Placed { k: usize, fi: usize, col: usize, - h: f64, + m: NoteMetrics, text: Arc, } +/// Vertical metrics of a composed footnote, relative to the top of its composed text. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct NoteMetrics { + /// First and last baselines (the first one includes the First Baseline offset). + first: f64, + last: f64, + /// Leading of the first line: its baseline's distance below the previous footnote's last. + lead: f64, + /// Descent of the last line. + descent: f64, +} + +/// Height of footnotes stacked baseline to baseline, from the top of the footnote area to the +/// last footnote's descent. +fn stack_height<'a>(notes: impl IntoIterator, space_between: f64) -> f64 { + let mut h: Option = None; + let mut descent = 0.0; + for m in notes { + let last = match h { + None => m.last, + Some(prev) => prev + space_between + m.lead + (m.last - m.first), + }; + h = Some(last); + descent = m.descent; + } + h.map_or(0.0, |h| h + descent) +} + pub(crate) struct Notes { /// Byte offsets of the reference characters (index = footnote index). refs: Vec, @@ -36,7 +69,7 @@ pub(crate) struct Notes { pub key: Option, pub placed: Vec, /// Composed footnote text by (index, label, width). - cache: HashMap<(usize, String, u64), (Arc, f64)>, + cache: HashMap<(usize, String, u64), (Arc, NoteMetrics)>, notes: Vec>, } @@ -88,7 +121,7 @@ impl Notes { v } - fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc, f64) { + fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc, NoteMetrics) { let o = &doc.footnote_options; let label = format!("{}{}", o.label(self.numbers[k], true), o.separator); let key = (k, label.clone(), w.to_bits()); @@ -115,8 +148,12 @@ impl Notes { xrefs: opts.xrefs.clone(), }; let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts); - let h = cs.frames.first().and_then(|ft| ft.lines.last()).map_or(0.0, |l| l.baseline + l.descent); - let v = (Arc::new(cs), h); + let lines = cs.frames.first().map(|ft| ft.lines.as_slice()).unwrap_or_default(); + let m = match (lines.first(), lines.last()) { + (Some(a), Some(z)) => NoteMetrics { first: a.baseline, last: z.baseline, lead: a.leading, descent: z.descent }, + _ => NoteMetrics::default(), + }; + let v = (Arc::new(cs), m); self.cache.insert(key, v.clone()); v } @@ -125,25 +162,20 @@ impl Notes { #[allow(clippy::too_many_arguments)] pub fn reserve(&mut self, doc: &Document, fi: usize, col: usize, extra: &[usize], w: f64, f: &FrameSpec, opts: &ComposeOptions) -> f64 { 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) { - n += 1; - h += p.h; - } + let (space_between, space_before) = (o.space_between, o.space_before); + let mut ms: Vec = self.placed.iter().filter(|p| p.fi == fi && p.col == col).map(|p| p.m).collect(); for &k in extra { - n += 1; - h += self.compose(doc, k, w, f, opts).1; + ms.push(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 } + if ms.is_empty() { 0.0 } else { stack_height(&ms, space_between) + space_before } } pub fn place(&mut self, doc: &Document, k: usize, fi: usize, col: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) { if self.placed.iter().any(|p| p.k == k) { return; } - let (text, h) = self.compose(doc, k, w, f, opts); - self.placed.push(Placed { k, fi, col, h, text }); + let (text, m) = self.compose(doc, k, w, f, opts); + self.placed.push(Placed { k, fi, col, m, text }); } /// Stack each column's footnotes against its bottom, rule above the first. @@ -159,7 +191,7 @@ impl Notes { 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 total: f64 = here.iter().map(|p| p.h).sum::() + o.space_between * (here.len() - 1) as f64; + let total = stack_height(here.iter().map(|p| &p.m), o.space_between); let top = col.y1 - total; let ft = &mut out.frames[fi]; if o.rule.on && o.rule.weight > 0.0 { @@ -171,20 +203,29 @@ impl Notes { tint: o.rule.tint, }); } - let mut y = top; + // Previous footnote's last baseline and bottom. + let mut prev: Option<(f64, f64)> = None; for p in here { - let note = &story.notes[p.k]; + let Some(note) = story.notes.get(p.k) else { continue }; + let (y, y0) = match prev { + None => (top, top), + Some((last, bottom)) => { + let first = last + o.space_between + p.m.lead; + (first - p.m.first, (bottom + o.space_between).min(first)) + } + }; + let y1 = y + p.m.last + p.m.descent; ft.notes.push(PlacedNote { id: note.id, index: p.k, column: ci 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), + rect: Rect::new(col.x0, y0, col.x1, y1), text: p.text.clone(), source: Arc::from(note.text.text.as_str()), }); - y += p.h + o.space_between; + prev = Some((y + p.m.last, y1)); } } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..67f3592d1 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -832,6 +832,52 @@ fn footnotes_sit_at_the_column_bottom_and_push_text() { assert!(note_caret(&cs, n.id, 0).is_some()); } +#[test] +fn footnotes_stack_baseline_to_baseline() { + // Single-line footnotes read as one text: consecutive baselines sit one leading (plus + // Space Between Footnotes) apart, and only the last footnote's descent adds to the area. + let text = format!("{LOREM} {LOREM} {LOREM} {LOREM}"); + for space_between in [0.0, 3.0] { + let (mut d, sid, fid) = doc_with(&text, Rect::new(36.0, 36.0, 300.0, 236.0), ParaAttrs::default()); + d.footnote_options.space_between = space_between; + d.footnote_options.space_before = 4.0; + { + let st = d.story_mut(sid).unwrap(); + for at in [60, 40, 20, 10] { + st.insert_note(at, "Note.", ParaFormat::default()); + } + st.check().unwrap(); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 4); + let first_line = |n: &PlacedNote| { + let lines = &n.text.frames[0].lines; + assert_eq!(lines.len(), 1); + lines[0].clone() + }; + let baselines: Vec = ft.notes.iter().map(|n| n.origin.y + first_line(n).baseline).collect(); + let step = first_line(&ft.notes[1]).leading + space_between; + for w in baselines.windows(2) { + assert!((w[1] - w[0] - step).abs() < 1e-6, "baselines {baselines:?}, step {step}"); + } + for w in ft.notes.windows(2) { + assert!(w[0].rect.y1 <= w[1].rect.y0 + 1e-6, "rects stack in order"); + } + // Area: first-baseline offset + (n - 1) steps + the last descent, ending at the column bottom. + let col = ft.columns[0]; + let top = ft.notes[0].rect.y0; + let (l0, l3) = (first_line(&ft.notes[0]), first_line(&ft.notes[3])); + assert!((col.y1 - top - (l0.baseline + 3.0 * step + l3.descent)).abs() < 1e-6); + assert!((ft.notes[3].rect.y1 - col.y1).abs() < 1e-6); + // The body text fills the room left above the footnotes and their space before. + let room = top - d.footnote_options.space_before; + let last = ft.lines.last().unwrap(); + assert!(last.baseline + last.descent <= room + 0.01, "text clears the footnotes"); + assert!(last.baseline + last.leading + last.descent > room, "one more line wouldn't fit"); + } +} + #[test] fn footnote_first_line_wraps_at_the_column_edge_after_its_number() { // InDesign sets the footnote number and separator as part of the first line: that line From bb45f12b05dd05aec3c556592cc70ddf3bf10ba5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 14:39:14 +0000 Subject: [PATCH 018/200] Place: with no frame selected, the graphic loads into the place cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Place (native picker, browser import, Place with Import Options and the PDF page dialog) passes loadCursor to file.place. With no empty or graphic frame selected and no x/y given, an image is added to the document and loaded into the place cursor instead of landing in a new frame on the first spread; a click places it at its natural size, a drag draws its frame. The loaded graphic keeps the chosen PDF page and crop box. Escape or choosing another tool unloads the cursor. Dropped files and file.place without loadCursor behave as before. --- crates/engine/src/cmd/file.rs | 43 ++++++++++++++++++++------- crates/engine/src/lib.rs | 14 +++++++-- crates/engine/src/tooling.rs | 4 +++ crates/tools/src/nav.rs | 7 +++++ crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/lib.rs | 56 ++++++++++++++++++++++++++++++----- crates/ui-egui/src/menus.rs | 2 +- 7 files changed, 106 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ed126eee5..aa2356493 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -100,7 +100,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}", + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}", has_doc, file_place ), @@ -336,6 +336,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let px = p.get("x").and_then(Value::as_f64); let py = p.get("y").and_then(Value::as_f64); let want_w = p.get("width").and_then(Value::as_f64); + if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { + let mime = designcraft_render::image_mime(&bytes).to_string(); + let asset = Asset { page: pdf_page, id: AssetId(0), name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }; + return load_cursor(s, asset, (nw, nh), crop_box); + } s.edit(|d, sel| { let fresh = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); @@ -442,18 +447,32 @@ fn place_load(s: &mut Session, p: &Value) -> Result { let (bytes, name, link) = read_source(p)?; let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; let natural = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64)); + let mime = designcraft_render::image_mime(&bytes).to_string(); + let asset = Asset { page: 0, id: AssetId(0), name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }; + load_cursor(s, asset, natural, None) +} + +/// Adds `asset` to the document (with a fresh id) and loads it into the place cursor. +fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64), crop: Option<(f64, f64, f64, f64)>) -> Result { + let name = asset.name.clone(); + let (pw, ph) = asset.pixels.unwrap_or_default(); let aid = s.edit(|d, _| { - let fresh = AssetId(d.alloc()); - let mime = designcraft_render::image_mime(&bytes).to_string(); - Ok(d.add_asset(Asset { page: 0, id: fresh, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) })) + asset.id = AssetId(d.alloc()); + Ok(d.add_asset(asset)) })?; - s.loaded = Some((aid, natural)); + s.loaded = Some(crate::LoadedGraphic { asset: aid, size, crop }); s.set_tool("placeGun"); - Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph})) + Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph, "loaded": true})) } fn place_drop(s: &mut Session, p: &Value) -> Result { - let (aid, (nw, nh)) = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; + let crate::LoadedGraphic { asset: aid, size: (nw, nh), crop } = + s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; + // Undo can remove the loaded graphic's asset. + if !s.doc()?.doc.assets.contains_key(&aid) { + s.loaded = None; + return Err(bad("place.drop", "the loaded graphic is no longer in the document")); + } let sr = super::spread_param(p, "spread"); let lid = s.doc()?.active_layer; let frame = super::id_param(p, "frame"); @@ -477,12 +496,14 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { } None => { // Drag: fit proportionally into the dragged rect; click: actual size at the point. + // The frame shows the crop box when one was chosen. + let (bx, by, bw, bh) = crop.unwrap_or((0.0, 0.0, nw, nh)); let (frame_r, k) = match rect { Some(r) if r.width() > 2.0 && r.height() > 2.0 => { - let k = (r.width() / nw).min(r.height() / nh); - (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) + let k = (r.width() / bw).min(r.height() / bh); + (Rect::new(r.x0, r.y0, r.x0 + bw * k, r.y0 + bh * k), k) } - _ => (Rect::new(x, y, x + nw, y + nh), 1.0), + _ => (Rect::new(x, y, x + bw, y + bh), 1.0), }; let id = ItemId(d.alloc()); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_r)); @@ -490,7 +511,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { it.content = Content::Graphic(Graphic { asset: aid, size: (nw, nh), - xf: Affine::translate((frame_r.x0, frame_r.y0)) * Affine::scale(k), + xf: Affine::translate((frame_r.x0 - bx * k, frame_r.y0 - by * k)) * Affine::scale(k), auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 771c89b13..29a396e39 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -105,6 +105,16 @@ pub struct DocState { #[derive(Debug)] pub struct FontScope(pub u32); +/// A graphic loaded in the place cursor (`place.load`, File › Place with no frame to fill). +#[derive(Clone, Copy, Debug)] +pub struct LoadedGraphic { + pub asset: designcraft_doc::AssetId, + /// Natural size in points. + pub size: (f64, f64), + /// Image Import Options › Crop to: the box of the graphic a new frame shows (x, y, w, h). + pub crop: Option<(f64, f64, f64, f64)>, +} + impl Drop for FontScope { fn drop(&mut self) { designcraft_fonts::FontDb::global().close_scope(self.0); @@ -258,8 +268,8 @@ pub struct Session { pub text_clipboard: Option<(Arc, String)>, /// Requests for the UI (dialogs, view changes) produced by tools/commands. pub ui_requests: Vec, - /// Graphic loaded in the place cursor: (asset, natural size in points). - pub loaded: Option<(designcraft_doc::AssetId, (f64, f64))>, + /// Graphic loaded in the place cursor. + pub loaded: Option, pub(crate) untitled: u32, /// Transforms applied to the current selection, oldest first (Transform Again / Sequence /// Again), with the selection they were applied to and whether the last one came from a diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1bf8db930..e562ec144 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -59,6 +59,10 @@ impl Session { return; } self.tool = designcraft_tools::create(id); + // Any other tool unloads the place cursor. + if id != "placeGun" { + self.loaded = None; + } // Leaving the Type tool keeps the text frame selected. if id != "type" && let Some(st) = self.active_mut() diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index d892b090a..fa9621650 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -157,6 +157,13 @@ impl Tool for PlaceGun { None => vec![], } } + fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { + match key { + // Escape unloads the place cursor. + crate::ToolKey::Escape => vec![Action::SwitchTool("selection".into())], + _ => vec![], + } + } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { Cursor::LoadedGraphic } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3bad..da0a2ad06 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1921,7 +1921,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { if let Some(target) = d.fields.get("target").and_then(Value::as_u64) { app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?; } - app.open_file("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) + app.place_file(json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) } "print" => { let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3d94bdb03..ac25e6c23 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -622,7 +622,6 @@ impl DesignApp { /// command right away; asynchronous ones (web) deliver the file through the inbox. pub fn pick_and_open(&mut self, purpose: &str) -> Result { let target = self.session.active().map(|d| d.uid); - let cmd = if purpose == "place" { "file.place" } else { "file.open" }; if let Some(pick) = self.services.pick_open.as_mut() { return match pick(purpose) { // Image Import Options: a multi-page PDF asks which page. @@ -634,10 +633,11 @@ impl DesignApp { Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target}))); Ok(Value::Null) } else { - self.open_file(cmd, json!({"path": path})) + self.place_file(json!({"path": path})) } } - Some(path) => self.open_file(cmd, json!({"path": path})), + Some(path) if purpose == "place" => self.place_file(json!({"path": path})), + Some(path) => self.open_file("file.open", json!({"path": path})), None => Ok(Value::Null), }; } @@ -669,10 +669,8 @@ impl DesignApp { pub(crate) fn confirm_pdf_import(&mut self, page: u64, crop: String) -> Result { let file = self.pending_pdf.take().ok_or("PDF import cancelled: no pending file")?; self.activate_import_target(&file.request)?; - self.open_file( - "file.place", - json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop}), - ) + let params = json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop}); + if file.request.purpose == "place" { self.place_file(params) } else { self.open_file("file.place", params) } } /// A single unresolved PDF owns its bytes. Later deliveries cancel explicitly rather than @@ -726,6 +724,8 @@ impl DesignApp { let b64 = designcraft_engine::cmd::base64_encode(&file.bytes); if lower.ends_with(".ase") || file.request.purpose == "swatches" { self.run("swatch.load", json!({"base64": b64})) + } else if file.request.purpose == "place" { + self.place_file(json!({"name": file.name, "base64": b64})) } else if place { self.open_file("file.place", json!({"name": file.name, "base64": b64})) } else { @@ -810,6 +810,15 @@ impl DesignApp { self.open_file(cmd, json!({"path": path})) } + /// File › Place: the file fills the selected empty or graphic frame; with none, an image loads + /// into the place cursor (click to place at its natural size, drag to draw the frame). + pub(crate) fn place_file(&mut self, mut params: Value) -> Result { + if let Some(o) = params.as_object_mut() { + o.insert("loadCursor".into(), json!(true)); + } + self.open_file("file.place", params) + } + /// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file /// that can't be opened or placed gets an alert with the reason, as well as the status line. /// A successful import with warnings keeps the document and shows the warnings in an alert. @@ -1625,6 +1634,7 @@ mod browser_import_tests { assert_eq!(*asset.data, bytes); assert!(app.pending_pdf.is_none()); assert!(app.session.documents()[1].doc.assets.is_empty()); + app.run("place.drop", json!({"x": 36, "y": 36})).unwrap(); let exported = app.run("file.exportPdf", json!({})).unwrap(); let exported = designcraft_engine::cmd::base64_decode(exported["base64"].as_str().unwrap()); let pixels = designcraft_render::render_pdf_page(&exported, 0, 400).unwrap(); @@ -1632,6 +1642,38 @@ mod browser_import_tests { assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0); } + #[test] + fn place_loads_the_cursor_unless_a_frame_can_take_the_graphic() { + let mut app = app(); + let items = |app: &DesignApp| app.session.doc().unwrap().doc.spreads[0].items.len(); + // Nothing selected: the graphic waits in the place cursor; Escape unloads it. + let request = app.import_request("place"); + deliver(&mut app, request, "red.svg", image()); + assert_eq!(app.session.tool_id(), "placeGun"); + assert!(app.session.loaded.is_some()); + assert_eq!(items(&app), 0); + let mods = designcraft_tools::Mods::default(); + app.session.tool_key(designcraft_tools::ToolKey::Escape, mods, designcraft_engine::ViewInfo::at_zoom(1.0)).unwrap(); + assert_eq!(app.session.tool_id(), "selection"); + assert!(app.session.loaded.is_none()); + // A click with the loaded cursor places the graphic at its natural size. + let request = app.import_request("place"); + deliver(&mut app, request, "red.svg", image()); + app.run("place.drop", json!({"x": 100, "y": 100})).unwrap(); + let it = app.session.doc().unwrap().doc.spreads[0].items.last().unwrap(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 100.0, 107.5, 107.5), "10 CSS px = 7.5 pt"); + // An empty graphic frame selected: the graphic fills it. + let f = app.run("frame.create", json!({"rect": [200, 200, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + let request = app.import_request("place"); + deliver(&mut app, request, "red.svg", image()); + assert_eq!(app.session.tool_id(), "selection"); + assert!(app.session.loaded.is_none()); + assert_eq!(items(&app), 2); + let frame = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap(); + assert!(matches!(frame.content, designcraft_doc::Content::Graphic(_))); + } + #[test] fn pdf_options_reject_oversized_page_without_editing() { let mut app = app(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d123ec4eb..fee4653d2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -743,7 +743,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option v, From 1278957455becb2ddb155a62871a4960df842e63 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Mon, 12 Oct 2026 15:14:08 +0000 Subject: [PATCH 019/200] Text wrap: the minimum gap applies only where a wrap object reaches the column free_slot held a column to the minimum gap width (1.5 em) whenever a wrap object overlapped the line's band vertically, even if the object sat beside another column of the frame: in a frame of narrow columns, an object beside column 2 dropped column 1's lines. The minimum now applies only when the object reaches the column itself (aiongg's review on #589). Test: wrap_object_beside_one_narrow_column_leaves_the_other_alone (two 18 pt columns in a 14 pt paragraph, an object beside column 2) fails before (overset) and passes after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/compose/src/lib.rs | 8 +++++--- crates/compose/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5ab809649..cc29bee0e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2080,8 +2080,8 @@ fn line_metrics( /// Widest free horizontal interval of `col` in the band, or None if blocked. A gap a wrap object /// cuts narrower than 1.5 em of `size` is skipped (a sliver beside the object); when no object -/// reaches the band the whole column is free, however narrow (a one-character frame such as an -/// article's closing tombstone). +/// reaches the column in the band the whole column is free, however narrow (a one-character frame +/// such as an article's closing tombstone, or a narrow column beside another that wraps). fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> { let mut free = vec![(col.x0, col.x1)]; let mut cut = false; @@ -2092,7 +2092,9 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => { - cut = true; + // Held to the minimum width only when the object reaches this column: one beside + // another column of the frame leaves this one whole, however narrow. + cut |= r.x0 < col.x1 && r.x1 > col.x0; let mut next = Vec::new(); for (a, b) in free { if r.x1 <= a || r.x0 >= b { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b0cc474c7..9e5ad3f25 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -184,6 +184,25 @@ fn narrow_frame_sets_lines_no_wrap_object_reaches() { assert!(baseline - top < 20.0, "on the first line: {baseline} (text area from {top})"); } +/// In a frame of narrow columns, a wrap object beside column 2 leaves column 1 alone: only a gap +/// the object cuts in its own column is held to the minimum width (it was dropped, as the object +/// overlapped the line's band, and the text went past column 1). +#[test] +fn wrap_object_beside_one_narrow_column_leaves_the_other_alone() { + // Two 18 pt columns (12 pt gutter): narrower than 1.5 em of the 14 pt paragraph. + let (mut d, sid) = tombstone(Rect::new(300.0, 300.0, 348.0, 340.0), Rect::new(336.0, 300.0, 380.0, 340.0)); + let fid = d.spreads[0].items.iter().find(|i| i.text_frame().is_some_and(|t| t.story == sid)).unwrap().id; + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let f = &cs.frames[0]; + assert_eq!(f.columns.len(), 2); + assert!(f.columns[0].width() < 21.0, "column 1 is narrower than 1.5 em: {}", f.columns[0].width()); + assert!(!cs.is_overset()); + let l = &f.lines[0]; + assert_eq!(l.column, 0, "set in column 1"); + assert!(l.baseline - f.columns[0].y0 < 20.0, "on its first line: {}", l.baseline); +} + #[test] fn paragraph_composer_is_no_worse_than_greedy() { // Sum of squared slack over lines (excluding last) should not exceed the greedy result. From 04319fce0f3a484caaac3add4f5e2eace212f0bb Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:37:29 -0600 Subject: [PATCH 020/200] Styles: selection capture accepts deleted-swatch fills Validate caller-supplied character fills before merging selection attributes. Deleted swatch names already retained in text no longer block New Style from Selection; explicit unknown fills still fail without changing state. Cover swatch creation, text formatting, deletion and style capture, including non-fill overrides and explicit-fill rejection with fromSelection. --- crates/engine/src/cmd/style.rs | 44 +++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4bf69ec41..e60d2a1d7 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -429,6 +429,8 @@ fn create_para(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?; let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // Validate caller-supplied fills, not selection fills retained after a swatch is deleted. + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) && let Ok(cur) = s.execute("type.selectionAttrs", &json!({})) && !cur.is_null() @@ -444,7 +446,6 @@ fn create_para(s: &mut Session, p: &Value) -> Result { chars = full; } } - super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; let based_on = str_param(p, "basedOn").map(str::to_string); let next = str_param(p, "nextStyle").map(str::to_string); s.edit(|d, _| { @@ -1116,17 +1117,38 @@ mod color_tests { } #[test] - fn char_fill_rejects_inherited_unknown_name_when_creating_from_selection() { - let mut session = fill_session(); - session - .edit(|document, selection| { - let selected = selection.text.unwrap(); - document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); - Ok(()) - }) - .unwrap(); + fn char_fill_preserves_deleted_swatch_when_creating_from_selection() { + for params in + [json!({"name": "Inherited", "fromSelection": true}), json!({"name": "Inherited", "fromSelection": true, "chars": {"size": 24}})] + { + let mut session = fill_session(); + let swatch = session.execute("swatch.create", &json!({"name": "Deleted Ink", "color": "#108080"})).unwrap(); + let fill = swatch["name"].as_str().unwrap(); + session.execute("type.char", &json!({"fill": fill})).unwrap(); + session.execute("swatch.delete", &json!({"name": fill})).unwrap(); + assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); - assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true})); + session.execute("style.paragraph.create", ¶ms).unwrap(); + + let chars = &session.doc().unwrap().doc.styles.para("Inherited").unwrap().chars; + assert_eq!(chars.fill.as_deref(), Some(fill)); + if params.get("chars").is_some() { + assert_eq!(chars.size, Some(24.0)); + } + } + } + + #[test] + fn char_fill_rejects_explicit_unknown_name_when_creating_from_selection() { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + + assert_fill_rejected( + &mut session, + "style.paragraph.create", + json!({"name": "Explicit", "fromSelection": true, "chars": {"fill": fill, "size": 24}}), + ); + } } #[test] From 2d2c6bbc839957465009efd2ff5bff93bede6f50 Mon Sep 17 00:00:00 2001 From: Jeffrey Thayne <36243577+thaynej@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:48:42 -0600 Subject: [PATCH 021/200] Links: placed text files can stay linked (Word, RTF, text) File > Place of a text file with `link: true`, or with the new Preferences > File Handling > Create Links When Placing Text Files, records on the story the file's path, size and hash, and the style map and style-conflict option used. The Links panel and links.list show these files OK / Modified / Missing like graphics. links.update imports a modified file again into the same frames and thread with the saved style map (Auto Rename's choices are kept, so no new _wrd_ styles). As in InDesign, edits made in the layout are replaced: the Links panel and Window > Links > Update Link ask first (Update / Cancel) when a linked story was edited in the layout, while the links.update command updates and returns a warning, as InDesign scripting does. links.relink, links.goTo and links.embed (Unlink) take {story}. Saved in the native format; IDML writes the text unlinked. Tracked changes import as accepted, as before. Tests cover place-with-link, Modified/Missing status, update with the saved style map keeping frames, the local-edits warning, the confirmation dialog (confirm, cancel, no dialog for unedited stories), relink, unlink, the preference, and a native save/load round trip. Closes #597. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/story.rs | 27 ++ crates/engine/src/cmd/file.rs | 4 +- crates/engine/src/cmd/linked.rs | 4 +- crates/engine/src/cmd/links.rs | 322 ++++++++++++++++++++++-- crates/engine/src/cmd/place_text.rs | 112 +++++++-- crates/engine/src/cmd/prefs.rs | 2 +- crates/engine/src/lib.rs | 4 + crates/idml/src/import.rs | 1 + crates/ui-egui/src/dialogs.rs | 57 ++++- crates/ui-egui/src/menus.rs | 5 + crates/ui-egui/src/panels/properties.rs | 66 ++++- docs/target-app-parity.md | 1 + 13 files changed, 553 insertions(+), 53 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..56bfdb7d6 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-12** · Linked text files (#597): placed Word, RTF and text files can stay linked; the Links panel shows them OK / Modified / Missing, and Update imports the file again into the same frames with the saved style map (the Links panel asks before replacing edits made in the layout). 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/doc/src/story.rs b/crates/doc/src/story.rs index 6dbfa13c6..22562340d 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -155,6 +155,32 @@ pub struct Story { /// Place and Link: the parent story this one copies, and the parent's revision when copied. #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option<(StoryId, u64)>, + /// File › Place with Create Links: the text file the story was placed from (Links panel). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_link: Option, +} + +/// A linked text file: where a placed story came from, what the file held, and the Word Import +/// Options that brought it in, so Update Link can import it again the same way. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextFileLink { + pub path: String, + /// The file's size and FNV-1a hash (hex) when it was placed or last updated. + pub size: u64, + pub hash: String, + /// Imported style name → document style (`styleMap`), including the names Auto Rename chose. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub style_map: BTreeMap, + /// `styleConflicts`: useExisting, redefine or autoRename. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub style_conflicts: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub remove_styles: bool, + /// Hash of the story's text and formatting right after the import: a different one means it + /// was edited in the layout (an update replaces those edits). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub story_hash: String, } impl Story { @@ -177,6 +203,7 @@ impl Story { objects: Vec::new(), editorial: Vec::new(), link: None, + file_link: None, } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b5887bb50..4432fddad 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -100,7 +100,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}", + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link?: keep the file linked (Links panel; default: the linkTextFiles preference)}", has_doc, file_place ), @@ -291,7 +291,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); + return super::place_text::place_text(s, p, &name, &bytes, link); } // 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/linked.rs b/crates/engine/src/cmd/linked.rs index ec07a24b2..96939afb9 100644 --- a/crates/engine/src/cmd/linked.rs +++ b/crates/engine/src/cmd/linked.rs @@ -11,8 +11,8 @@ use crate::Result; fn copy_into(d: &mut Document, parent: StoryId, child: StoryId) -> Result<()> { let src = d.story(parent).ok_or_else(|| bad("story.link", format!("no story {}", parent.0)))?.clone(); let st = d.story_mut(child).ok_or_else(|| bad("story.link", format!("no story {}", child.0)))?; - let (frames, rev) = (std::mem::take(&mut st.frames), st.rev); - *st = Story { id: child, frames, rev: rev + 1, link: Some((parent, src.rev)), ..src }; + let (frames, rev, file_link) = (std::mem::take(&mut st.frames), st.rev, st.file_link.take()); + *st = Story { id: child, frames, rev: rev + 1, link: Some((parent, src.rev)), file_link, ..src }; Ok(()) } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 73de3ea7e..1f9512953 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -4,10 +4,13 @@ //! Every placed graphic keeps its bytes in the document; a linked graphic also remembers its //! file. Its status compares that file with the copy in the document: missing, modified (the //! file's bytes differ) or OK. +//! +//! A linked text file (File › Place with Create Links) remembers its file's size and hash on the +//! story instead; updating it imports the file again into the story. use std::sync::Arc; -use designcraft_doc::{AssetId, Content, Document, ItemId, Selection}; +use designcraft_doc::{AssetId, Content, Document, ItemId, Selection, Story, StoryId, TextFileLink}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; @@ -15,7 +18,7 @@ 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}]}]", has_doc, |s, _| { + 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, edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { Ok(Value::Array(list(&s.doc()?.doc))) }), cmd!( @@ -23,7 +26,7 @@ pub fn specs() -> Vec { "Relink…", ["Window", "Links"], None, - "{asset, path} — use another file for every placement of the graphic", + "{asset | story, path} — use another file for every placement of the graphic, or import another text file into the linked story", has_doc, relink ), @@ -41,22 +44,38 @@ pub fn specs() -> Vec { "Update Link", ["Window", "Links"], None, - "{asset?} — reload modified links from their files (all when no asset)", + "{asset? | story?} — reload modified links from their files (all when neither); a linked text file is imported again into its story with the import options it was placed with, replacing edits made in the layout → {updated, warnings}", has_doc, update ), - cmd!("links.embed", "Embed Link", ["Window", "Links"], None, "{asset} — keep only the copy in the document", has_doc, |s, p| { - let aid = asset_param(p, "links.embed")?; - s.edit(|d, _| { - let a = d.assets.get_mut(&aid).ok_or_else(|| bad("links.embed", "no such graphic"))?; - Arc::make_mut(a).link = None; - Ok(Value::Null) - }) - }), - cmd!(noundo "links.goTo", "Go To Link", ["Window", "Links"], None, "{asset} — selects the frames showing the graphic", has_doc, |s, p| { - let aid = asset_param(p, "links.goTo")?; + cmd!( + "links.embed", + "Embed Link", + ["Window", "Links"], + None, + "{asset | story} — keep only the copy in the document (a text file: unlink the story)", + has_doc, + |s, p| { + if let Some(sid) = story_param(p) { + return s.edit(|d, _| { + d.story_mut(sid).ok_or_else(|| bad("links.embed", "no such story"))?.file_link = None; + Ok(Value::Null) + }); + } + let aid = asset_param(p, "links.embed")?; + s.edit(|d, _| { + let a = d.assets.get_mut(&aid).ok_or_else(|| bad("links.embed", "no such graphic"))?; + Arc::make_mut(a).link = None; + Ok(Value::Null) + }) + } + ), + cmd!(noundo "links.goTo", "Go To Link", ["Window", "Links"], None, "{asset | story} — selects the frames showing the graphic or the linked text", has_doc, |s, p| { let st = s.doc_mut()?; - let ids = uses(&st.doc, aid); + let ids = match story_param(p) { + Some(sid) => st.doc.story(sid).map(|s| s.frames.clone()).unwrap_or_default(), + None => uses(&st.doc, asset_param(p, "links.goTo")?), + }; if ids.is_empty() { return Err(bad("links.goTo", "the graphic isn't placed")); } @@ -71,6 +90,21 @@ fn asset_param(p: &Value, cmd: &str) -> Result { p.get("asset").and_then(Value::as_u64).map(AssetId).ok_or_else(|| bad(cmd, "missing asset")) } +fn story_param(p: &Value) -> Option { + p.get("story").and_then(Value::as_u64).map(StoryId) +} + +/// FNV-1a (64-bit) of `bytes`, in hex: a linked text file's fingerprint. +pub(crate) fn fnv_hex(bytes: &[u8]) -> String { + let h = bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0000_0100_0000_01b3)); + format!("{h:016x}") +} + +/// Fingerprint of a story's text and formatting (edits made in the layout change it). +pub(crate) fn story_hash(st: &Story) -> String { + fnv_hex(serde_json::to_string(&(&st.text, &st.paras, &st.chars)).unwrap_or_default().as_bytes()) +} + /// Frames (graphic content) showing an asset, top-level or in groups. fn uses(d: &Document, aid: AssetId) -> Vec { let mut out = Vec::new(); @@ -111,7 +145,37 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str { } } +/// Link status of a linked text file. +pub fn text_status(l: &TextFileLink) -> &'static str { + #[cfg(not(target_arch = "wasm32"))] + { + match std::fs::metadata(&l.path) { + Err(_) => "missing", + Ok(m) if m.len() != l.size => "modified", + Ok(_) => match std::fs::read(&l.path) { + Ok(b) if fnv_hex(&b) == l.hash => "ok", + Ok(_) => "modified", + Err(_) => "missing", + }, + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = l; + "ok" + } +} + +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 { + 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})) + }); let used = d.used_assets(); d.assets .values() @@ -140,9 +204,30 @@ fn list(d: &Document) -> Vec { .collect(); json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "uses": uses}) }) + .chain(texts) .collect() } +#[cfg(not(target_arch = "wasm32"))] +fn read_text(path: &str) -> Result> { + std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} + +#[cfg(target_arch = "wasm32")] +fn read_text(path: &str) -> Result> { + 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> { + 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)?; + 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)); + } + Ok(warnings) +} + #[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}")))?; @@ -156,8 +241,16 @@ fn reload(path: &str) -> Result<(Vec, (u32, u32))> { } fn relink(s: &mut Session, p: &Value) -> Result { - let aid = asset_param(p, "links.relink")?; 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 name = file_name(&path); + return s.edit(|d, _| { + let warnings = reimport(d, sid, &path, &bytes)?; + Ok(json!({"name": name, "warnings": warnings})) + }); + } + let aid = asset_param(p, "links.relink")?; let (bytes, px) = reload(&path)?; let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); s.edit(|d, _| { @@ -210,18 +303,31 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { fn update(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; + 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 .assets .values() - .filter(|a| p.get("asset").and_then(Value::as_u64).is_none_or(|x| x == a.id.0)) + .filter(|a| all || only_asset == Some(a.id.0)) .filter(|a| status(a) == "modified") .filter_map(|a| a.link.clone().map(|l| (a.id, l))) .collect(); + let texts: Vec<(StoryId, String)> = d + .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()))) + .collect(); let mut fresh = Vec::new(); for (aid, path) in which { fresh.push((aid, reload(&path)?)); } - let n = fresh.len(); + let mut fresh_texts = Vec::new(); + for (sid, path) in texts { + let bytes = read_text(&path)?; + fresh_texts.push((sid, path, bytes)); + } + let n = fresh.len() + fresh_texts.len(); if n == 0 { return Ok(json!({"updated": 0})); } @@ -233,7 +339,11 @@ fn update(s: &mut Session, p: &Value) -> Result { a.pixels = Some(*px); } } - Ok(json!({"updated": n})) + let mut warnings = Vec::new(); + for (sid, path, bytes) in &fresh_texts { + warnings.extend(reimport(d, *sid, path, bytes)?); + } + Ok(json!({"updated": n, "warnings": warnings})) }) } @@ -428,3 +538,177 @@ mod shared_asset_tests { assert_eq!(dst.assets.len(), 2); } } + +#[cfg(test)] +mod text_link_tests { + use serde_json::{Value, json}; + + use crate::Session; + use designcraft_doc::{ItemId, StoryId}; + + /// A small Word file: a Title paragraph, then Body paragraphs (`body` is WordprocessingML). + fn docx(body: &str) -> Vec { + use std::io::Write; + const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let styles = format!( + r#" + "# + ); + let doc = format!( + r#"Head + {body}"# + ); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (n, c) in [("word/document.xml", doc), ("word/styles.xml", styles)] { + w.start_file(n, o).unwrap(); + w.write_all(c.as_bytes()).unwrap(); + } + w.finish().unwrap().into_inner() + } + + fn text_rows(s: &mut Session) -> Vec { + let l = s.execute("links.list", &json!({})).unwrap(); + l.as_array().unwrap().iter().filter(|r| r.get("story").is_some()).cloned().collect() + } + + fn story(s: &Session, sid: StoryId) -> designcraft_doc::Story { + s.doc().unwrap().doc.story(sid).unwrap().clone() + } + + fn styles(st: &designcraft_doc::Story) -> Vec { + st.paras.iter().map(|p| p.style.clone()).collect() + } + + #[test] + fn linked_word_file_status_update_relink_and_unlink() { + let dir = std::env::temp_dir().join(format!("dc-text-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("moved")).unwrap(); + let path = dir.join("chapter.docx"); + std::fs::write(&path, docx("First draft.")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 9}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Headline", "chars": {"size": 30}})).unwrap(); + // Two threaded frames. + let a = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text"})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "spread": 1, "content": "text"})).unwrap()["id"].as_u64().unwrap(); + s.edit(|d, _| Ok(d.thread(ItemId(a), ItemId(b))?)).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + + // Place and link, with a style map and Auto Rename. + let r = s + .execute( + "file.place", + &json!({"path": path.to_string_lossy(), "frame": a, "link": true, "styleMap": {"Title": "Headline"}, "styleConflicts": "autoRename"}), + ) + .unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let placed = story(&s, sid); + assert_eq!(placed.text, "Head\nFirst draft."); + assert_eq!(styles(&placed), ["Headline", "Body_wrd_1"]); + let link = placed.file_link.clone().unwrap(); + assert_eq!(link.path, path.to_string_lossy()); + assert_eq!(link.style_conflicts, "autoRename"); + assert_eq!(link.style_map.get("Title").map(String::as_str), Some("Headline")); + assert_eq!(link.style_map.get("Body").map(String::as_str), Some("Body_wrd_1"), "Auto Rename's choice is kept"); + let rows = text_rows(&mut s); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0]["story"].as_u64(), rows[0]["name"].as_str()), (Some(sid.0), Some("chapter.docx"))); + assert_eq!((rows[0]["status"].as_str(), rows[0]["edited"].as_bool()), (Some("ok"), Some(false))); + assert_eq!(rows[0]["uses"].as_array().unwrap().len(), 2, "both threaded frames"); + + // The file changes (with tracked changes): Modified; Update imports it as if accepted, with + // the saved style map, into the same frames, without new renamed styles. + std::fs::write( + &path, + docx(r#"Second revised old draft."#), + ) + .unwrap(); + assert_eq!(text_rows(&mut s)[0]["status"], "modified"); + let r = s.execute("links.update", &json!({"story": sid.0})).unwrap(); + assert_eq!(r["updated"], 1); + assert_eq!(r["warnings"], json!([]), "no edits in the layout to replace"); + let updated = story(&s, sid); + assert_eq!(updated.text, "Head\nSecond revised draft."); + assert_eq!(styles(&updated), ["Headline", "Body_wrd_1"]); + assert_eq!(updated.frames, [ItemId(a), ItemId(b)], "frames and thread kept"); + assert!(s.doc().unwrap().doc.styles.para("Body_wrd_2").is_none()); + assert!(s.doc().unwrap().doc.styles.para("Title").is_none(), "mapped styles aren't imported"); + assert_eq!(text_rows(&mut s)[0]["status"], "ok"); + + // An edit in the layout, then the file changes again: Update replaces the edit and warns. + s.execute("story.setText", &json!({"story": sid.0, "text": "Fixed in layout"})).unwrap(); + assert_eq!(text_rows(&mut s)[0]["edited"], true); + std::fs::write(&path, docx("Third draft.")).unwrap(); + let r = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(r["updated"], 1); + let w = r["warnings"][0].as_str().unwrap(); + assert!(w.contains("chapter.docx") && w.contains("replaced"), "{w}"); + assert_eq!(story(&s, sid).text, "Head\nThird draft."); + assert_eq!(text_rows(&mut s)[0]["edited"], false); + // Undo brings the edit back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(story(&s, sid).text, "Fixed in layout"); + s.execute("edit.redo", &json!({})).unwrap(); + + // 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"); + + // The file moves: Missing; Relink to it imports it into the story. + let moved = dir.join("moved").join("chapter.docx"); + std::fs::write(&moved, docx("Moved draft.")).unwrap(); + std::fs::remove_file(&path).unwrap(); + assert_eq!(text_rows(&mut s)[0]["status"], "missing"); + s.execute("links.relink", &json!({"story": sid.0, "path": moved.to_string_lossy()})).unwrap(); + let rows = text_rows(&mut s); + assert_eq!((rows[0]["status"].as_str(), rows[0]["path"].as_str()), (Some("ok"), Some(moved.to_string_lossy().as_ref()))); + assert_eq!(story(&s, sid).text, "Head\nMoved draft."); + + // Go To selects the story's frames; Unlink keeps the text and drops the file. + let r = s.execute("links.goTo", &json!({"story": sid.0})).unwrap(); + assert_eq!(r["ids"], json!([a, b])); + s.execute("links.embed", &json!({"story": sid.0})).unwrap(); + assert!(text_rows(&mut s).is_empty()); + assert_eq!(story(&s, sid).text, "Head\nMoved draft."); + 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())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("notes.txt"); + std::fs::write(&path, "Plain text").unwrap(); + let place = |s: &mut Session, extra: Value| { + let mut p = json!({"path": path.to_string_lossy(), "rect": [72, 72, 300, 200]}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("file.place", &p).unwrap(); + (s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap().file_link.is_some(), r) + }; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert!(!place(&mut s, json!({})).0, "off by default"); + assert!(place(&mut s, json!({"link": true})).0); + s.execute("prefs.set", &json!({"linkTextFiles": true})).unwrap(); + assert!(place(&mut s, json!({})).0, "the preference links"); + assert!(!place(&mut s, json!({"link": false})).0, "`link` overrides it"); + // Placed from bytes: no file to link. + let b64 = crate::cmd::base64_encode(b"Plain text"); + let r = s.execute("file.place", &json!({"base64": b64, "name": "notes.txt", "rect": [72, 300, 300, 400]})).unwrap(); + assert!(s.doc().unwrap().doc.story(StoryId(r["story"].as_u64().unwrap())).unwrap().file_link.is_none()); + // Into an insertion point: not linked, with a warning. + let fr = s.execute("frame.create", &json!({"rect": [72, 500, 300, 600], "content": "text", "text": "Start end"})).unwrap(); + let sid = fr["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 6})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap(); + assert!(r["warnings"][0].as_str().unwrap().contains("not linked"), "{r}"); + assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().file_link.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 97917ee3b..698f38809 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -2,7 +2,7 @@ //! selected frame, or a new frame on the page; with autoflow, pages and threaded frames are added //! until the whole story fits (InDesign's Shift-click with a loaded text cursor). -use designcraft_doc::{CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextSel}; +use designcraft_doc::{CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextFileLink, TextSel}; use designcraft_geom::Rect; use serde_json::{Value, json}; @@ -22,8 +22,33 @@ enum Conflict { AutoRename, } +impl Conflict { + fn parse(c: &str) -> Result { + match c { + "useExisting" | "" => Ok(Conflict::UseExisting), + "redefine" => Ok(Conflict::Redefine), + "autoRename" => Ok(Conflict::AutoRename), + c => Err(bad("file.place", format!("unknown styleConflicts `{c}` (useExisting, redefine, autoRename)"))), + } + } + + fn name(self) -> &'static str { + match self { + Conflict::UseExisting => "useExisting", + Conflict::Redefine => "redefine", + Conflict::AutoRename => "autoRename", + } + } +} + /// Map imported style names (`styleMap`) and resolve name clashes in the import before it lands. -fn map_styles(d: &Document, imp: &mut designcraft_textimport::Imported, map: &std::collections::HashMap, conflict: Conflict) { +/// Returns `map` with the names Auto Rename chose. +fn map_styles( + d: &Document, + imp: &mut designcraft_textimport::Imported, + map: &std::collections::HashMap, + conflict: Conflict, +) -> std::collections::HashMap { let mut rename: std::collections::HashMap = map.clone(); if conflict == Conflict::AutoRename { let st = &d.styles; @@ -39,7 +64,7 @@ fn map_styles(d: &Document, imp: &mut designcraft_textimport::Imported, map: &st } } if rename.is_empty() { - return; + return rename; } // Mapped styles aren't imported; renamed ones come in under their new name. imp.para_styles.retain(|s| !map.contains_key(&s.name)); @@ -63,6 +88,7 @@ fn map_styles(d: &Document, imp: &mut designcraft_textimport::Imported, map: &st f.style = m.clone(); } }); + rename } fn add_styles(d: &mut Document, imp: &designcraft_textimport::Imported, conflict: Conflict) { @@ -105,6 +131,60 @@ fn strip(story: &mut Story) { story.format_chars(0..n, |f| *f = Default::default()); } +/// The story's whole text replaced by the import's (its frames stay). +fn fill(story: &mut Story, from: &Story) { + let n = story.len(); + story.delete(0..n); + story.insert_story(0, from); + if let (Some(first), Some(p0)) = (from.paras.first(), story.paras.first_mut()) { + *p0 = ParaFormat { table: p0.table, ..first.clone() }; + } +} + +/// The link a story placed from `path` keeps (Create Links When Placing Text Files). +fn text_link( + path: &str, + bytes: &[u8], + map: std::collections::HashMap, + conflict: Conflict, + remove: bool, + story: &Story, +) -> TextFileLink { + TextFileLink { + path: path.to_string(), + size: bytes.len() as u64, + hash: super::links::fnv_hex(bytes), + style_map: map.into_iter().collect(), + style_conflicts: conflict.name().into(), + remove_styles: remove, + story_hash: super::links::story_hash(story), + } +} + +/// 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> { + 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()); + let mut imp = designcraft_textimport::import(&name, bytes).map_err(|e| bad("links.update", format!("{name}: {e}")))?; + if link.remove_styles { + strip(&mut imp.story); + imp.para_styles.clear(); + imp.char_styles.clear(); + } + let conflict = Conflict::parse(&link.style_conflicts)?; + // A mapping to a style the document no longer has is dropped: that style is imported again. + let map: std::collections::HashMap = + link.style_map.into_iter().filter(|(_, to)| d.styles.para(to).is_some() || d.styles.char_style(to).is_some()).collect(); + let map = map_styles(d, &mut imp, &map, conflict); + 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)); + Ok(imp.warnings) +} + /// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)? /// A frame a column, frame or page break ends early is left out: it says nothing about how much /// a frame holds. @@ -154,7 +234,7 @@ 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]) -> Result { +pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8], path: 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 { @@ -162,18 +242,15 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - imp.para_styles.clear(); imp.char_styles.clear(); } - let conflict = match p.get("styleConflicts").and_then(Value::as_str).unwrap_or("useExisting") { - "useExisting" => Conflict::UseExisting, - "redefine" => Conflict::Redefine, - "autoRename" => Conflict::AutoRename, - c => return Err(bad("file.place", format!("unknown styleConflicts `{c}` (useExisting, redefine, autoRename)"))), - }; + let conflict = Conflict::parse(p.get("styleConflicts").and_then(Value::as_str).unwrap_or("useExisting"))?; let map: std::collections::HashMap = p .get("styleMap") .and_then(Value::as_object) .map(|o| o.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))).collect()) .unwrap_or_default(); - map_styles(&s.doc()?.doc, &mut imp, &map, conflict); + let map = map_styles(&s.doc()?.doc, &mut imp, &map, conflict); + // Create Links When Placing Text Files (only a file on disk can be linked). + let link = path.filter(|_| p.get("link").and_then(Value::as_bool).unwrap_or(s.prefs.link_text_files)); let autoflow_on = p.get("autoflow").and_then(Value::as_bool).unwrap_or(false); let st = s.doc()?; let caret = st.selection.text.filter(|t| t.cell.is_none()); @@ -182,7 +259,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - }); let page = p.get("page").and_then(Value::as_u64).map(|v| (v as usize).saturating_sub(1)); let rect = super::rect_param(p, "rect"); - let warnings = imp.warnings.clone(); + let mut warnings = imp.warnings.clone(); s.edit(|d, sel| { add_styles(d, &imp, conflict); let sid = if let Some(t) = caret { @@ -192,6 +269,9 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - story.delete(r.clone()); let end = story.insert_story(r.start, &imp.story); sel.text = Some(TextSel { anchor: end, focus: end, ..t }); + if link.is_some() { + warnings.push("not linked: a linked text file fills a whole story, not an insertion point".into()); + } t.story } else { let fid = match frame { @@ -221,12 +301,8 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - None => return Err(bad("file.place", "no such frame")), }; let story = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let n = story.len(); - story.delete(0..n); - story.insert_story(0, &imp.story); - if let Some(first) = imp.story.paras.first() { - story.paras[0] = ParaFormat { table: story.paras[0].table, ..first.clone() }; - } + fill(story, &imp.story); + story.file_link = link.as_deref().map(|path| text_link(path, bytes, map.clone(), conflict, remove, story)); *sel = designcraft_doc::Selection::items(vec![fid]); sid }; diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 68a5cf152..6c4908ffd 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Preferences", [], None, - "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], showFontNamesInEnglish?} → all application preferences", + "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?, transformationsAreTotals?, absolutePageNumbers?, highlightHj?, highlightKeeps?, highlightCustomTracking?, highlightSubstitutedFonts?, richBlackOutput?, favoriteFonts?: [family], showFontNamesInEnglish?, linkTextFiles? (Create Links When Placing Text Files)} → all application preferences", super::always, |s, p| { let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 164719305..ccd8bd922 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -195,6 +195,9 @@ pub struct Prefs { pub show_note_anchors: bool, /// Preferences › File Handling: minutes between document recovery saves. pub recovery_minutes: f64, + /// Preferences › File Handling › Create Links When Placing Text Files: placed text files are + /// linked (Links panel) unless `file.place` says otherwise. + pub link_text_files: bool, } /// A starter autocorrect list (common English typing slips). @@ -242,6 +245,7 @@ impl Default for Prefs { show_added_text: true, show_note_anchors: true, recovery_minutes: 0.5, + link_text_files: false, } } } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..399d186e2 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1596,6 +1596,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(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..7b2f56623 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -828,6 +828,9 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he d.fields.insert("recoveryMinutes".into(), json!(v)); } }); + ui.add_space(6.0); + ui.label(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Links")).font(semibold(12.0)))); + check(ui, d, "linkTextFiles", crate::i18n::tr(&app.ui.language, "Create Links When Placing Text Files")); } "type" => { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Type Options")).font(semibold(12.0))); @@ -1138,6 +1141,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "userDictionary" => crate::i18n::tr(&app.ui.language, "User Dictionary"), "newWorkspace" => crate::i18n::tr(&app.ui.language, "New Workspace"), "closeDocument" => crate::i18n::tr(&app.ui.language, "Unsaved Changes"), + "updateLink" => crate::i18n::tr(&app.ui.language, "Update Link"), "menus" => crate::i18n::tr(&app.ui.language, "Menu Customization"), "importOptions" => crate::i18n::tr(&app.ui.language, "Import Options"), "fittingOptions" => crate::i18n::tr(&app.ui.language, "Frame Fitting Options"), @@ -1160,7 +1164,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, - "closeDocument" => 380.0, + "closeDocument" | "updateLink" => 380.0, "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, _ => 640.0, }; @@ -1541,6 +1545,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { 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); } + "updateLink" => { + let text = crate::i18n::tr(&app.ui.language, "“{}” has been edited in this layout. Updating replaces those edits with the text from the file.") + .replace("{}", &d.s("files")); + crate::rtl::label(ui, text); + } "newWorkspace" => { ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); @@ -1703,7 +1712,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } }); ui.add_space(12.0); - let ok_label = if d.id == "closeDocument" { " Save " } else { " OK " }; + let ok_label = match d.id.as_str() { + "closeDocument" => " Save ", + "updateLink" => " Update ", + _ => " OK ", + }; ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui @@ -1990,7 +2003,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { "prefs.set", json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "transformationsAreTotals": d.b("transformationsAreTotals"), "absolutePageNumbers": d.b("absolutePageNumbers"), "highlightHj": d.b("highlightHj"), "highlightKeeps": d.b("highlightKeeps"), "highlightCustomTracking": d.b("highlightCustomTracking"), "highlightSubstitutedFonts": d.b("highlightSubstitutedFonts"), "richBlackOutput": d.b("richBlackOutput"), "typographersQuotes": d.b("typographersQuotes"), "showFontNamesInEnglish": d.b("showFontNamesInEnglish"), "smartTextReflow": d.b("smartTextReflow"), "autocorrect": d.b("autocorrect"), "showAddedText": d.b("showAddedText"), "showNoteAnchors": d.b("showNoteAnchors"), - "recoveryMinutes": d.n("recoveryMinutes").unwrap_or(0.5), + "recoveryMinutes": d.n("recoveryMinutes").unwrap_or(0.5), "linkTextFiles": d.b("linkTextFiles"), "autocorrectList": d.s("autocorrectText").lines().filter_map(|l| { let (a, b) = l.split_once('→').or_else(|| l.split_once("->"))?; let (a, b) = (a.trim().to_lowercase(), b.trim().to_string()); @@ -2075,6 +2088,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("document.preferences", doc) } "newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})), + "updateLink" => crate::panels::properties::run_update_links(app, d.fields.get("target").cloned().unwrap_or_else(|| json!({}))), "closeDocument" => { // The document may have moved in the tab row (or gone) while the dialog was open. let uid = d.fields.get("uid").and_then(Value::as_u64); @@ -4396,6 +4410,43 @@ mod tests { assert!(!app.ui.pdf_export.tagged); } + #[test] + fn update_link_asks_before_replacing_edits_made_in_the_layout() { + let dir = std::env::temp_dir().join(format!("dc-update-link-dialog-{}", 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, "One").unwrap(); + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let r = app.session.execute("file.place", &json!({"path": path.to_string_lossy(), "link": true, "rect": [72, 72, 300, 200]})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let text = |app: &crate::DesignApp| app.session.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone(); + + // Not edited in the layout: updates without asking. + std::fs::write(&path, "Two").unwrap(); + crate::panels::properties::update_links(&mut app, json!({"story": sid})); + assert!(app.ui.dialog.is_none()); + assert_eq!(text(&app), "Two"); + + // Edited in the layout: asks first; Cancel changes nothing. + app.session.execute("story.setText", &json!({"story": sid, "text": "Mine"})).unwrap(); + std::fs::write(&path, "Three").unwrap(); + crate::panels::properties::update_links(&mut app, json!({"story": sid})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.id.as_str(), d.s("files").as_str()), ("updateLink", "note.txt")); + app.ui.dialog = None; + assert_eq!(text(&app), "Mine"); + + // Window › Links › Update Link (all links) asks too; confirming updates. + crate::menus::activate(&mut app, "links.update", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("updateLink")); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(text(&app), "Three"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn confirm_pdf_export_cancelled_save_writes_nothing() { use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb1422394..b0fbb51fa 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1877,6 +1877,11 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { return; } let p = if params.is_null() { json!({}) } else { params.clone() }; + // Update Link asks before replacing edits made to linked text in the layout. + if id == "links.update" { + crate::panels::properties::update_links(app, p); + return; + } let _ = app.run(id, p); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..7d683a8ff 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1787,7 +1787,7 @@ pub fn align_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } /// Links: status (missing / modified / embedded), page and effective resolution of every placed -/// graphic; Relink, Go To Link, Update Link, Embed Link for the chosen row. +/// graphic and linked text file; Relink, Go To Link, Update Link, Embed Link for the chosen row. pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { // Linked stories (Place and Link). let stories = app.session.execute("story.links", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); @@ -1840,7 +1840,8 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page · PPI")).color(t.text_dim).size(11.0)); ui.end_row(); for r in &rows { - let aid = r["asset"].as_u64().unwrap_or(0); + // Asset and story ids come from one counter: a row is chosen by either. + let aid = r["asset"].as_u64().or_else(|| r["story"].as_u64()).unwrap_or(0); let name = r["name"].as_str().unwrap_or(""); if ui.selectable_label(chosen == Some(aid), name).clicked() { chosen = Some(aid); @@ -1880,19 +1881,39 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Choose a link.")).color(t.text_dim).size(11.0)); return; }; + // A linked text file is addressed by its story. + let is_text = rows.iter().any(|r| r["story"].as_u64() == Some(aid)); + let target = if is_text { json!({"story": aid}) } else { json!({"asset": aid}) }; + let with = |k: &str, v: Value| { + let mut p = target.clone(); + p[k] = v; + p + }; if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Relink…"))).clicked() && let Some(path) = app.services.pick_open.as_mut().and_then(|pick| pick("relink")) { - let _ = app.run("links.relink", json!({"asset": aid, "path": path})); + let _ = app.run("links.relink", with("path", json!(path))); } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Go To"))).clicked() { - let _ = app.run("links.goTo", json!({"asset": aid})); + let _ = app.run("links.goTo", target.clone()); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Update"))).clicked() { - let _ = app.run("links.update", json!({"asset": aid})); + let edited = rows.iter().any(|r| r["story"].as_u64() == Some(aid) && r["edited"].as_bool() == Some(true)); + let update = ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Update"))); + let update = if edited { + update.on_hover_ui(|ui| { + crate::rtl::label( + ui, + crate::i18n::tr(&app.ui.language, "This story was edited in the layout: you'll be asked before those edits are replaced"), + ); + }) + } else { + update + }; + if update.clicked() { + update_links(app, target.clone()); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Embed"))).clicked() { - let _ = app.run("links.embed", json!({"asset": aid})); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if is_text { "Unlink" } else { "Embed" }))).clicked() { + let _ = app.run("links.embed", target.clone()); } }); ui.horizontal(|ui| { @@ -1914,6 +1935,35 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// Update Link from the Links panel or the menu (`{asset}`, `{story}`, or `{}` for all): asks +/// first when a linked text story edited in the layout would lose those edits, as InDesign's +/// Links panel does. The `links.update` command itself doesn't ask (as in InDesign scripting). +pub fn update_links(app: &mut DesignApp, target: Value) { + let rows = app.session.execute("links.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + let one = target.get("story").and_then(Value::as_u64); + let all = one.is_none() && target.get("asset").is_none(); + let edited: Vec<&str> = rows + .iter() + .filter(|r| (all || (one.is_some() && r["story"].as_u64() == one)) && r["edited"] == true && r["status"] == "modified") + .filter_map(|r| r["name"].as_str()) + .collect(); + if edited.is_empty() { + let _ = run_update_links(app, target); + } else { + let files = edited.join(", "); + app.ui.dialog = Some(crate::dialogs::Dialog::new("updateLink", json!({"target": target, "files": files}))); + } +} + +/// Run `links.update`; its first warning goes to the status bar. +pub(crate) fn run_update_links(app: &mut DesignApp, target: Value) -> Result { + let r = app.run("links.update", target)?; + if let Some(w) = r["warnings"].as_array().and_then(|w| w.first()).and_then(Value::as_str) { + app.status(w.to_string()); + } + Ok(r) +} + pub fn info_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let u = units(app); let t = Tokens::get(ui.ctx()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5705ee738..8ed35dc8a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -285,6 +285,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. - Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. +- Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked. - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). From d8d66a27577a51f4a01574fcf3911df795df9c3e Mon Sep 17 00:00:00 2001 From: hasan-sdaif <306196098+hasan-sdaif@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:47:15 +0000 Subject: [PATCH 022/200] =?UTF-8?q?Complete=20the=20Arabic=20(=D8=A7=D9=84?= =?UTF-8?q?=D8=B9=D8=B1=D8=A8=D9=8A=D8=A9)=20catalog:=20every=20missing=20?= =?UTF-8?q?key=20translated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ar.rs covered 1,456 of the interface strings; the newer catalogs (it, ja, pt-br, uk) had since grown to a 1,690-key union. This adds the 238 keys Arabic was missing, in the same clean-room Modern Standard Arabic and InDesign-style vocabulary the file already uses (كائن مرسّى، مسار مغلق، خطوة وتكرار، الفحص المسبق، دمج البيانات، الكائن الأصل…): - the whole menu-path family (نص/…، كائن/…، جدول/…، نافذة/…) — Type on a Path, Track Changes, Story Direction, Transform Again, Pathfinder… - the special-character and white-space inventory (شرطة كاملة، مسافة شعرية، محرف نهاية الفقرة، علامات الاقتباس المستقيمة…) and their symbols (رمز حقوق النشر، رمز العلامة التجارية…) - data merge (سجل، سجلات في الصفحة، تحديد مصدر بيانات…), EPUB/HTML/XML/ IDML exports, proof setups and colour-blindness previews, anchored objects, parent pages, slug and bleed fields, quick apply, preflight. Same table, same lookups — Arabic now covers the union of every language's keys, matching Japanese's completeness (1,694 keys). --- crates/ui-egui/src/i18n/ar.rs | 240 ++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd2..84e061bf9 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1469,4 +1469,244 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules…", "خطوط الفقرة…"), ("Paragraph Rules", "خطوط الفقرة"), ("Rule Below", "خط سفلي"), + // Completion: the keys the newer catalogs grew (menu paths, special characters, + // data merge, EPUB exports, proof setups) — same clean-room Arabic as above. + ("90° CCW", "90° عكس عقارب الساعة"), + ("90° CW", "90° مع عقارب الساعة"), + ("A, B, C, D...", "أ، ب، ج، د..."), + ("All records", "كل السجلات"), + ("Alpha Channel", "قناة ألفا"), + ("Anchored Object Options", "خيارات الكائن المرسّى"), + ("Apply Parent to Pages", "تطبيق الأصل على الصفحات"), + ("ArtCraft App Page", "صفحة تطبيق ArtCraft"), + ("ArtCraft Website", "موقع ArtCraft"), + ("Bind to frame", "ربط بالإطار"), + ("Black", "أسود"), + ("Bleed:", "الامتداد:"), + ("Bottom:", "أسفل:"), + ("Bullet Character", "محرف التعداد"), + ("CMYK:", "CMYK:"), + ("Center in frame", "توسيط في الإطار"), + ("Check Spelling", "تدقيق الإملاء"), + ("Clear Rotation", "مسح التدوير"), + ("Closed Path", "مسار مغلق"), + ("Color Blindness – Deuteranopia", "عمى الألوان – الديوتيرانوبيا"), + ("Color Blindness – Protanopia", "عمى الألوان – البروتانوبيا"), + ("Column Break", "فاصل عمود"), + ("Column spacing", "تباعد الأعمدة"), + ("Columns first", "الأعمدة أولاً"), + ("Columns:", "الأعمدة:"), + ("Conditional Text", "نص شرطي"), + ("Copyright Symbol", "رمز حقوق النشر"), + ("Corner Options", "خيارات الزوايا"), + ("Create Guides", "إنشاء أدلة"), + ("Current Page Number", "رقم الصفحة الحالية"), + ("Cyan", "سماوي"), + ("Dark", "داكن"), + ("Data Merge", "دمج البيانات"), + ("Define Cross-Reference Format", "تعريف تنسيق المرجع المتصالب"), + ("Define Lists", "تعريف القوائم"), + ("Define Text Variable", "تعريف متغير نص"), + ("Delete Type from Path", "حذف النص من المسار"), + ("DesignCraft Website", "موقع DesignCraft"), + ("DesignCraft on GitHub", "DesignCraft على GitHub"), + ("Detect Edges", "كشف الحواف"), + ("Discretionary Hyphen", "واصلة تقديرية"), + ("Document CMYK", "CMYK المستند"), + ("Document Endnote Options", "خيارات الحاشية الختامية للمستند"), + ("Document Footnote Options", "خيارات الحاشية السفلية للمستند"), + ("Document RGB", "RGB المستند"), + ("Double Left Quotation Marks", "علامتا اقتباس يسرى مزدوجتان"), + ("Double Right Quotation Marks", "علامتا اقتباس يمنى مزدوجتان"), + ("Edit/Interface Language", "تحرير/لغة الواجهة"), + ("Edit/Spelling", "تحرير/الإملاء"), + ("Edit/Transparency Blend Space", "تحرير/فراغ مزج الشفافية"), + ("Edit/Transparency Flattener Presets", "تحرير/إعدادات تسطيح الشفافية المسبقة"), + ("Ellipsis", "علامة حذف"), + ("Em Dash", "شرطة كاملة"), + ("Em Space", "مسافة كاملة"), + ("En Dash", "شرطة نصف كاملة"), + ("En Space", "مسافة نصف كاملة"), + ("Export EPUB", "تصدير EPUB"), + ("Export EPUB (Fixed Layout)", "تصدير EPUB (تخطيط ثابت)"), + ("Export EPUB (Reflowable)", "تصدير EPUB (قابل لإعادة التدفق)"), + ("Export HTML", "تصدير HTML"), + ("Export IDML", "تصدير IDML"), + ("Export PDF (Interactive)", "تصدير PDF (تفاعلي)"), + ("Export Page as PNG", "تصدير الصفحة كـ PNG"), + ("Export Text", "تصدير النص"), + ("Export XML", "تصدير XML"), + ("Figure Space", "مسافة رقمية"), + ("File/Export", "ملف/تصدير"), + ("Fill proportionally", "تعبئة متناسبة"), + ("Fit content to frame", "ملاءمة المحتوى للإطار"), + ("Fit proportionally", "ملاءمة متناسبة"), + ("Flush Space", "مسافة محاذاة"), + ("Forced Line Break", "فاصل سطر قسري"), + ("Frame Break", "فاصل إطار"), + ("GREP", "GREP"), + ("Generate Index", "إنشاء الفهرس"), + ("Generate Live Caption", "إنشاء تسمية توضيحية حية"), + ("Global Light", "الإضاءة الشاملة"), + ("Graphic", "رسم"), + ("Hair Space", "مسافة شعرية"), + ("High Contrast", "تباين عالٍ"), + ("High Resolution", "دقة عالية"), + ("Horizontal:", "أفقي:"), + ("I, II, III, IV...", "أ، ب، ج، د..."), + ("Import XML", "استيراد XML"), + ("Indent to Here", "إزاحة بادئة إلى هنا"), + ("Ink Limit 300%", "حد الحبر 300%"), + ("Insert Anchored Object", "إدراج كائن مرسّى"), + ("Insert Cross-Reference", "إدراج مرجع متصالب"), + ("Insert Math Expression", "إدراج تعبير رياضي"), + ("Insert Pages", "إدراج صفحات"), + ("Insert field", "إدراج حقل"), + ("Internet Standard RGB (sRGB)", "RGB القياسي للإنترنت (sRGB)"), + ("Join the ArtCraft Discord", "انضم إلى ArtCraft على Discord"), + ("Layout/Index", "تخطيط/الفهرس"), + ("Layout/Pages", "تخطيط/الصفحات"), + ("Left:", "يسار:"), + ("Legacy Macintosh RGB", "RGB ماكينتوش القديم"), + ("Light", "فاتح"), + ("Limit", "حد"), + ("Link images", "ربط الصور"), + ("Load DTD", "تحميل DTD"), + ("Load Swatches", "تحميل العينات"), + ("Low Resolution", "دقة منخفضة"), + ("Magenta", "أرجواني"), + ("Margins and Columns", "الهوامش والأعمدة"), + ("Margins:", "الهوامش:"), + ("Medium Dark", "داكن متوسط"), + ("Medium Light", "فاتح متوسط"), + ("Medium Resolution", "دقة متوسطة"), + ("Menus", "القوائم"), + ("Merge options", "خيارات الدمج"), + ("Monitor RGB", "RGB الشاشة"), + ("Move Pages", "نقل الصفحات"), + ("Multiple records", "سجلات متعددة"), + ("New Condition", "شرط جديد"), + ("New Hyperlink", "ارتباط تشعبي جديد"), + ("New Hyperlink Destination", "وجهة ارتباط تشعبي جديدة"), + ("New Page Reference", "مرجع صفحة جديد"), + ("New Parent", "أصل جديد"), + ("New Stroke Style", "نمط حد جديد"), + ("Next", "التالي"), + ("Next Page Number", "رقم الصفحة التالية"), + ("No data source", "لا مصدر بيانات"), + ("Nonbreaking Hyphen", "واصلة غير فاصلة"), + ("Nonbreaking Space", "مسافة غير فاصلة"), + ("None (keep transparency)", "بلا (الإبقاء على الشفافية)"), + ("Numbering & Section Options", "خيارات الترقيم والمقاطع"), + ("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", "كائن/تحويل مجدداً"), + ("One record", "سجل واحد"), + ("Open Path", "مسار مفتوح"), + ("Package", "تجميع"), + ("Page Break", "فاصل صفحات"), + ("Paragraph Return", "محرف نهاية الفقرة"), + ("Paragraph Symbol", "رمز الفقرة"), + ("Pass csv, rows, or bytes from the control channel.", "مرّر csv أو rows أو bytes عبر قناة التحكم."), + ("Picas", "بيكا"), + ("Place with Import Options", "وضع مع خيارات الاستيراد"), + ("Preflight", "الفحص المسبق"), + ("Previous", "السابق"), + ("Previous Page Number", "رقم الصفحة السابقة"), + ("Print Booklet", "طباعة كتيّب"), + ("Punctuation Space", "مسافة ترقيم"), + ("Quarter Space", "مسافة ربعية"), + ("Quick Apply", "تطبيق سريع"), + ("RGB:", "RGB:"), + ("Range", "نطاق"), + ("Record", "سجل"), + ("Records per page", "سجلات في الصفحة"), + ("Registered Trademark Symbol", "رمز العلامة التجارية المسجلة"), + ("Relink", "إعادة ربط"), + ("Relink to Folder", "إعادة ربط إلى مجلد"), + ("Report an Issue", "الإبلاغ عن مشكلة"), + ("Right Indent Tab", "جدولة مسافة بادئة يمنى"), + ("Right:", "يمين:"), + ("Row spacing", "تباعد الصفوف"), + ("Rows first", "الصفوف أولاً"), + ("Save As", "حفظ باسم"), + ("Save Swatches for Exchange", "حفظ العينات للتبادل"), + ("Save a Copy", "حفظ نسخة"), + ("Scripts", "البرمجيات النصية"), + ("Section Marker", "علامة المقطع"), + ("Section Symbol", "رمز المقطع"), + ("Select Data Source…", "تحديد مصدر بيانات…"), + ("Sentence case", "حالة الجملة"), + ("Simulate Paper Color", "محاكاة لون الورق"), + ("Single Left Quotation Mark", "علامة اقتباس يسرى مفردة"), + ("Single Right Quotation Mark", "علامة اقتباس يمنى مفردة"), + ("Single record", "سجل مفرد"), + ("Sixth Space", "مسافة سدسية"), + ("Slug:", "الشريط:"), + ("Smart Guide Options", "خيارات الأدلة الذكية"), + ("Step and Repeat", "خطوة وتكرار"), + ("Story Column Direction", "اتجاه أعمدة التنسيق"), + ("Straight Double Quotation Marks", "علامتا اقتباس مستقيمتان مزدوجتان"), + ("Straight Single Quotation Mark (Apostrophe)", "علامة اقتباس مستقيمة مفردة (فاصلة عليا)"), + ("Subscript:", "رمز سفلي:"), + ("Superscript:", "رمز علوي:"), + ("Tab", "جدولة"), + ("Table of Contents", "جدول المحتويات"), + ("Table/Convert Cell Type", "جدول/تحويل نوع الخلية"), + ("Table/Delete", "جدول/حذف"), + ("Table/Insert", "جدول/إدراج"), + ("Table/Select", "جدول/تحديد"), + ("Thin Space", "مسافة رفيعة"), + ("Third Space", "مسافة ثلثية"), + ("Title Case", "حالة العنوان"), + ("Top:", "أعلى:"), + ("Trademark Symbol", "رمز العلامة التجارية"), + ("Transform Again Individually", "تحويل مجدداً بشكل فردي"), + ("Transform Sequence Again", "تحويل التسلسل مجدداً"), + ("Transform Sequence Again Individually", "تحويل التسلسل مجدداً بشكل فردي"), + ("Type on a Path Options", "خيارات النص على مسار"), + ("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/Proof Setup", "نص/إعداد التدقيق"), + ("Type/Rotate Spread", "نص/تدوير النطاق"), + ("Type/Separations Preview", "نص/معاينة الفصل"), + ("Type/Story Direction", "نص/اتجاه التنسيق"), + ("Type/Text Variables", "نص/متغيرات النص"), + ("Type/Track Changes", "نص/تعقب التغييرات"), + ("Type/Type on a Path", "نص/النص على مسار"), + ("URL:", "URL:"), + ("Unassigned", "غير معيّن"), + ("Vertical:", "رأسي:"), + ("View/Display Performance", "عرض/أداء العرض"), + ("View/Grids & Guides", "عرض/الشبكات والأدلة"), + ("Window/Arrange", "نافذة/ترتيب"), + ("Window/Interactive", "نافذة/تفاعلي"), + ("Window/Interface Color Theme", "نافذة/سمة ألوان الواجهة"), + ("Window/Output", "نافذة/الإخراج"), + ("Window/Panels", "نافذة/اللوحات"), + ("Window/Type & Tables", "نافذة/النص والجداول"), + ("Window/Utilities", "نافذة/الأدوات المساعدة"), + ("Working CMYK", "CMYK العامل"), + ("Yellow", "أصفر"), + ("a, b, c, d...", "أ، ب، ج، د..."), + ("i, ii, iii, iv...", "أ، ب، ج، د..."), + ("lowercase", "صغير"), ]; From 401fe3bb67dcb4a7c1bdac257b4de2b855d7fa69 Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 10:57:59 -0400 Subject: [PATCH 023/200] 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 024/200] 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 025/200] 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 026/200] 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 027/200] 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 028/200] 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 f605ca3157085d0cf2de06be0af0ae13a50f85be Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:16:17 +0000 Subject: [PATCH 029/200] chore(contributors): add Jason Brashear --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 67a8fdf30..9f9644959 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -30,3 +30,6 @@ sync_github_name = true [people.thaynej] real_name = "Jeffrey Thayne" + +[people.webdevtodayjason] +display_name = "Jason Brashear" From 98f1340f61a44f3681d8f2d3cac6298b10be5912 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:16:17 +0000 Subject: [PATCH 030/200] fix(index): keep level 3 and 4 topics in a run-in index --- crates/doc/src/index.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/index.rs b/crates/doc/src/index.rs index 0c2e58f4d..84d8caf6a 100644 --- a/crates/doc/src/index.rs +++ b/crates/doc/src/index.rs @@ -184,10 +184,15 @@ fn entry_text(spec: &IndexSpec, n: &Node, page_name: &dyn Fn(usize) -> String) - t } +// `build` limits topic paths to four levels, bounding this traversal. +fn run_in_subtopics(spec: &IndexSpec, n: &Node, page_name: &dyn Fn(usize) -> String) -> Vec { + n.children.values().flat_map(|c| [entry_text(spec, c, page_name)].into_iter().chain(run_in_subtopics(spec, c, page_name))).collect() +} + fn emit(spec: &IndexSpec, n: &Node, level: usize, page_name: &dyn Fn(usize) -> String, out: &mut Vec<(String, String)>) { if spec.run_in && level == 1 { let mut t = entry_text(spec, n, page_name); - let subs: Vec = n.children.values().map(|c| entry_text(spec, c, page_name)).collect(); + let subs = run_in_subtopics(spec, n, page_name); if !subs.is_empty() { t.push_str(": "); t.push_str(&subs.join(&spec.between_entries)); @@ -209,6 +214,19 @@ mod tests { Resolved { r: IndexRef { topics: topics.iter().map(|s| s.to_string()).collect(), sort: vec![], range }, pages } } + #[test] + fn run_in_keeps_all_topic_levels_in_sort_order() { + let refs = vec![ + r(&["Typography", "kerning", "pairs", "optical"], IndexRange::CurrentPage, Some((4, 4))), + r(&["Typography", "kerning", "pairs"], IndexRange::CurrentPage, Some((2, 2))), + r(&["Typography", "leading"], IndexRange::CurrentPage, Some((6, 6))), + r(&["Typography", "kerning", "adjustment"], IndexRange::See("Spacing".into()), None), + ]; + let spec = IndexSpec { run_in: true, title: String::new(), section_headings: false, between_entries: " / ".into(), ..IndexSpec::default() }; + let out = build(&spec, &refs, &|p| (p + 1).to_string()); + assert_eq!(out, [("Index Level 1".into(), "Typography: kerning / adjustment See Spacing / pairs 3 / optical 5 / leading 7".into())]); + } + #[test] fn builds_nested_index() { let refs = vec![ From 7515dfeaf50135f133fbe91cf5284206e06b1069 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 20:59:17 +0000 Subject: [PATCH 031/200] Word import: bullets and numbering from numbering.xml (#626) Paragraphs with w:numPr, directly or through their paragraph style, become bullet or numbered paragraphs: Word's number formats, label text, start numbers and overrides, level restarts, the text after the label and the level's indents. Parent levels in a label are written as text. Unreadable or hostile list definitions leave plain paragraphs. --- ROADMAP.md | 1 + crates/textimport/src/docx.rs | 294 ++++++++++++++++++++- crates/textimport/src/docx_numbering.rs | 325 ++++++++++++++++++++++++ crates/textimport/src/lib.rs | 3 +- docs/file-format-parity.md | 2 +- 5 files changed, 617 insertions(+), 8 deletions(-) create mode 100644 crates/textimport/src/docx_numbering.rs diff --git a/ROADMAP.md b/ROADMAP.md index f826a3767..9caf72aae 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-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 67a2b71a1..a51e9fe02 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -4,9 +4,10 @@ use std::collections::HashMap; use std::io::Read; use designcraft_doc::story::{BASIC_PARAGRAPH, NO_CHAR_STYLE}; -use designcraft_doc::{CharAttrs, CharFormat, ParaAttrs, ParaFormat, Story, StoryId, Table}; +use designcraft_doc::{CharAttrs, CharFormat, ListType, ParaAttrs, ParaFormat, Story, StoryId, Table}; use quick_xml::events::Event; +use crate::docx_numbering::{ListPara, Numbering}; use crate::{ImportError, Imported, ImportedStyle}; /// A minimal element tree (local names, attributes by local name). @@ -43,10 +44,10 @@ impl El { .collect() } /// `` → the value; a bare `` counts as "on". - fn val(&self, name: &str) -> Option { + pub(crate) fn val(&self, name: &str) -> Option { self.child(name).map(|e| e.attr("val").unwrap_or("true").to_string()) } - fn on(&self, name: &str) -> Option { + pub(crate) fn on(&self, name: &str) -> Option { self.val(name).map(|v| !matches!(v.as_str(), "0" | "false" | "none")) } } @@ -216,6 +217,12 @@ struct Ctx { footnotes: HashMap, warnings: Vec, next_table: u64, + numbering: Numbering, + /// A paragraph style's list (`w:numPr` numId and ilvl, through `w:basedOn`), by style id. + style_lists: HashMap, Option)>, + /// The number the model's own list counter is at (it restarts after a paragraph that isn't + /// in a list), to tell where a numbered paragraph needs an explicit Start At. + model_counter: u32, } impl Ctx { @@ -236,10 +243,20 @@ impl Ctx { first = false; let ppr = e.child("pPr"); let style = self.para_style(ppr); - let attrs = para_attrs(ppr); + let mut attrs = para_attrs(ppr); + let list = self.list(ppr); + if let Some(l) = &list { + // The level's indents, unless the paragraph sets its own. + let direct = attrs; + attrs = l.attrs.clone(); + attrs.merge(&direct); + } let at = st.len(); st.format_paras(at..at, |f| *f = ParaFormat { style: style.clone(), para: attrs.clone(), ..Default::default() }); self.inline(e, st, allow_notes); + if let Some(n) = self.start_at(list.as_ref(), st.len() == at) { + st.format_paras(at..at, |f| f.para.start_at = Some(Some(n))); + } } "tbl" => { let rows: Vec> = e @@ -271,6 +288,35 @@ impl Ctx { } } + /// The list a paragraph is in (its own `w:numPr`, else its style's), counting it. + fn list(&mut self, ppr: Option<&El>) -> Option { + let direct = ppr.and_then(|p| p.child("numPr")); + let style = ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.style_lists.get(&id)); + let num_id = direct.and_then(|n| n.val("numId")).or_else(|| style.and_then(|s| s.0.clone()))?; + let ilvl = direct.and_then(|n| n.val("ilvl")).or_else(|| style.and_then(|s| s.1.clone())); + // A level that isn't a small number is no list. + let ilvl = ilvl.map_or(Some(0), |l| l.trim().parse::().ok())?; + self.numbering.next(num_id.trim(), ilvl) + } + + /// Start At for a paragraph, so that the model numbers it as Word does: only where the + /// model's counter (which follows the previous numbered paragraph, and restarts after one + /// that isn't in a list) would give a different number. + fn start_at(&mut self, list: Option<&ListPara>, empty: bool) -> Option { + let Some(l) = list else { + self.model_counter = 0; + return None; + }; + // Bullets and empty paragraphs leave the counter alone (they show no number). + if l.attrs.list_type != Some(ListType::Numbers) || empty { + return None; + } + let want = l.number.max(1); + let next = self.model_counter.saturating_add(1); + self.model_counter = want; + (want != next).then_some(want) + } + /// Runs (and hyperlinks, fields' results) of a paragraph. fn inline(&mut self, p: &El, st: &mut Story, allow_notes: bool) { for e in p.els() { @@ -302,7 +348,9 @@ impl Ctx { "footnoteReference" if allow_notes => { if let Some(note) = e.attr("id").and_then(|id| self.footnotes.get(id).cloned()) { let mut ns = Story::new(StoryId(0)); + let counter = std::mem::take(&mut self.model_counter); self.blocks(¬e, &mut ns, false); + self.model_counter = counter; // Word puts the reference mark in the note text: we generate the number. let len = st.len(); let id = st.insert_note(len, "", ParaFormat::default()); @@ -328,17 +376,63 @@ impl Ctx { } } +/// Largest `word/numbering.xml` read. +const NUMBERING_LIMIT: u64 = 16 * 1024 * 1024; +/// Deepest `w:basedOn` chain followed (a cycle stops here too). +const MAX_STYLE_DEPTH: usize = 32; + +/// Each paragraph style's list: the first `w:numId` and `w:ilvl` on its `w:basedOn` chain. +fn style_lists(styles: &El) -> HashMap, Option)> { + let by_id: HashMap<&str, &El> = styles.els().filter(|s| s.name == "style").filter_map(|s| Some((s.attr("styleId")?, s))).collect(); + let mut out = HashMap::new(); + for (id, s) in &by_id { + let (mut num_id, mut ilvl) = (None, None); + let mut cur = Some(*s); + for _ in 0..MAX_STYLE_DEPTH { + let Some(c) = cur else { break }; + if let Some(n) = c.child("pPr").and_then(|p| p.child("numPr")) { + num_id = num_id.or_else(|| n.val("numId")); + ilvl = ilvl.or_else(|| n.val("ilvl")); + } + if num_id.is_some() && ilvl.is_some() { + break; + } + cur = c.val("basedOn").and_then(|b| by_id.get(b.as_str()).copied()); + } + if num_id.is_some() { + out.insert(id.to_string(), (num_id, ilvl)); + } + } + out +} + pub fn import(bytes: &[u8]) -> Result { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?; let doc = part(&mut zip, "word/document.xml")?.ok_or_else(|| ImportError::Corrupt("no word/document.xml (not a Word document)".into()))?; let doc = parse(&doc)?; // Styles. - let mut ctx = Ctx { para_names: HashMap::new(), char_names: HashMap::new(), footnotes: HashMap::new(), warnings: vec![], next_table: 0 }; + let mut ctx = Ctx { + para_names: HashMap::new(), + char_names: HashMap::new(), + footnotes: HashMap::new(), + warnings: vec![], + next_table: 0, + numbering: Numbering::default(), + style_lists: HashMap::new(), + model_counter: 0, + }; + // Lists that can't be read leave plain paragraphs, not a failed import. + match part_capped(&mut zip, "word/numbering.xml", NUMBERING_LIMIT).and_then(|s| s.map(|s| parse(&s)).transpose()) { + Ok(Some(n)) => ctx.numbering = Numbering::parse(&n), + Ok(None) => {} + Err(_) => ctx.warnings.push("The Word file's list definitions could not be read: lists were imported as plain paragraphs.".into()), + } let mut para_styles = Vec::new(); let mut char_styles = Vec::new(); if let Some(styles) = part(&mut zip, "word/styles.xml")?.map(|s| parse(&s)).transpose()? { let ids: HashMap = styles.els().filter(|s| s.name == "style").filter_map(|s| Some((s.attr("styleId")?.to_string(), s.val("name")?))).collect(); + ctx.style_lists = style_lists(&styles); for s in styles.els().filter(|s| s.name == "style") { let (Some(id), Some(name)) = (s.attr("styleId"), s.val("name")) else { continue }; let kind = s.attr("type").unwrap_or("paragraph"); @@ -404,9 +498,14 @@ mod tests { use std::io::Write; pub(crate) fn docx(document: &str, styles: &str, footnotes: &str) -> Vec { + docx_parts(&[("word/document.xml", document), ("word/styles.xml", styles), ("word/footnotes.xml", footnotes)]) + } + + /// A zip of `(part name, XML)`; empty parts are left out. + fn docx_parts(parts: &[(&str, &str)]) -> Vec { let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default(); - for (n, c) in [("word/document.xml", document), ("word/styles.xml", styles), ("word/footnotes.xml", footnotes)] { + for &(n, c) in parts { if c.is_empty() { continue; } @@ -510,4 +609,187 @@ mod tests { i.story.check().unwrap(); assert_eq!(i.story.text, "Kept text. MOVED HERE. Plain INSERTED\nEnd."); } + + /// A paragraph in a Word list: `w:numPr` with `numId` and, unless `None`, `ilvl`. + fn list_p(num_id: &str, ilvl: Option<&str>, text: &str) -> String { + let ilvl = ilvl.map(|l| format!(r#""#)).unwrap_or_default(); + format!(r#"{ilvl}{text}"#) + } + + fn plain_p(text: &str) -> String { + format!(r#"{text}"#) + } + + fn lists_docx(numbering: &str, styles: &str, body: &[String]) -> Imported { + let document = format!(r#"{}"#, body.concat()); + let i = import(&docx_parts(&[("word/document.xml", &document), ("word/styles.xml", styles), ("word/numbering.xml", numbering)])).unwrap(); + i.story.check().unwrap(); + i + } + + /// The label the model gives paragraph `i` when its number is `n`. + fn label(st: &Story, i: usize, n: u32) -> String { + let mut p = designcraft_doc::ParaProps::default(); + p.apply(&st.paras[i].para); + match p.list_type { + ListType::Numbers => p.number_label(n), + ListType::Bullets => p.bullet_label(), + ListType::None => String::new(), + } + } + + /// Regression (#626): pandoc's Markdown list example lost its bullets and numbers. + #[test] + fn bullets_and_numbers_from_numbering_xml() { + let numbering = format!( + r#" + + + + + + + "# + ); + let body = [ + plain_p("Intro paragraph."), + list_p("1000", Some("0"), "first item"), + list_p("1000", Some("0"), "second item"), + list_p("1001", Some("0"), "step one"), + list_p("1001", Some("0"), "step two"), + ]; + let st = lists_docx(&numbering, "", &body).story; + let types: Vec> = st.paras.iter().map(|p| p.para.list_type).collect(); + assert_eq!(types, [None, Some(ListType::Bullets), Some(ListType::Bullets), Some(ListType::Numbers), Some(ListType::Numbers)]); + assert_eq!(label(&st, 1, 1), "•\t"); + assert_eq!(st.paras[3].para.number_style, Some(designcraft_doc::NumberStyle::Arabic)); + assert_eq!((label(&st, 3, 1), label(&st, 4, 2)), ("1.\t".to_string(), "2.\t".to_string())); + // The model's own counter already starts at 1 and carries on: no Start At needed. + assert_eq!((st.paras[3].para.start_at, st.paras[4].para.start_at), (None, None)); + assert_eq!((st.paras[1].para.left_indent, st.paras[1].para.first_line_indent), (Some(24.0), Some(-24.0))); + } + + /// Level 1 inside level 0, parent numbers in a label, numbering that carries on past a plain + /// paragraph, and a `num` with a start override restarting. + #[test] + fn nested_levels_and_restarts() { + let numbering = format!( + r#" + + + + + + + + + + "# + ); + let body = [ + list_p("1", None, "A"), + list_p("1", Some("1"), "B"), + list_p("1", Some("1"), "C"), + list_p("1", Some("2"), "D"), + list_p("1", Some("0"), "E"), + plain_p("Between"), + list_p("1", Some("0"), "F"), + list_p("2", Some("0"), "G"), + list_p("3", Some("0"), "H"), + ]; + let st = lists_docx(&numbering, "", &body).story; + let start: Vec>> = st.paras.iter().map(|p| p.para.start_at).collect(); + // Word's numbers: 1, a, b, 1.b.I, 2, –, 3, 1 (own counter), 4 (shares numId 1's). E's 2 + // follows the model's counter (1 after D). + assert_eq!(start, [None, Some(Some(1)), None, Some(Some(1)), None, None, Some(Some(3)), Some(Some(1)), Some(Some(4))]); + assert_eq!(label(&st, 1, 1), "a) "); + assert_eq!(label(&st, 3, 1), "1.b.I\t"); + assert_eq!(st.paras[1].para.left_indent, Some(72.0)); + assert_eq!(st.paras[1].para.first_line_indent, Some(-18.0)); + } + + /// A paragraph style's `w:numPr` (inherited through `w:basedOn`) makes its paragraphs a list; + /// numId 0 on a paragraph takes it out of the list. + #[test] + fn style_lists_and_num_id_zero() { + let numbering = format!( + r#" + + + + + + "# + ); + let styles = format!( + r#" + + + + "# + ); + let styled = + |id: &str, extra: &str, text: &str| format!(r#"{extra}{text}"#); + let body = [ + styled("ListBullet", "", "one"), + styled("ListBullet2", "", "two"), + styled("ListBullet", r#""#, "not a list"), + ]; + let st = lists_docx(&numbering, &styles, &body).story; + assert_eq!(label(&st, 0, 1), "•\t"); + assert_eq!(label(&st, 1, 1), "◦\t"); + assert_eq!(st.paras[2].para.list_type, None); + assert_eq!(st.paras[2].style, "List Bullet"); + } + + /// Hostile list definitions: missing abstractNums and levels, huge or negative levels and + /// starts, a long label, a `w:basedOn` cycle. No panic; what can't be a list is plain. + #[test] + fn hostile_numbering_falls_back_to_plain_paragraphs() { + let long = "x".repeat(10_000); + let numbering = format!( + r#" + + + + + + + + + + + "# + ); + let styles = format!( + r#" + + + "# + ); + let body = [ + list_p("2", None, "missing abstractNum"), + list_p("3", None, "num without abstractNum"), + list_p("404", None, "unknown numId"), + list_p("1", Some("99999999999999"), "huge level"), + list_p("1", Some("9"), "level 9"), + list_p("1", Some("-1"), "negative level"), + list_p("1", Some("1"), "undefined level"), + r#"cycle"#.to_string(), + list_p("1", None, "huge start"), + list_p("1", Some("3"), "negative start"), + ]; + let st = lists_docx(&numbering, &styles, &body).story; + assert!(st.paras[..8].iter().all(|p| p.para.list_type.is_none()), "{:?}", st.paras); + assert_eq!(st.paras[8].para.start_at, Some(Some(u32::MAX))); + let expr = st.paras[8].para.number_expression.clone().unwrap_or_default(); + assert!(expr.starts_with("^#x") && expr.chars().count() < 80, "{expr}"); + // A start below 0 counts from 0, which the model shows as 1; `^` in a label is literal. + assert_eq!(label(&st, 9, 1), "1^\t"); + // Unreadable list definitions leave plain paragraphs and a warning, not a failed import. + let document = format!(r#"{}"#, list_p("1", None, "x")); + let i = import(&docx_parts(&[("word/document.xml", &document), ("word/numbering.xml", "")])).unwrap(); + assert_eq!(i.story.paras[0].para.list_type, None); + assert_eq!(i.warnings.len(), 1); + } } diff --git a/crates/textimport/src/docx_numbering.rs b/crates/textimport/src/docx_numbering.rs new file mode 100644 index 000000000..31b855b09 --- /dev/null +++ b/crates/textimport/src/docx_numbering.rs @@ -0,0 +1,325 @@ +//! Word lists: `word/numbering.xml` and the `w:numPr` of paragraphs and paragraph styles. +//! +//! A `w:num` (numId) points at a `w:abstractNum`, whose `w:lvl` elements (levels 0–8) give +//! each level's format, label text, start number, indents and the text after the label. +//! +//! Numbering follows Word's main rules, simplified: +//! - Numbers count per level. A paragraph at level L restarts the levels below it (unless a +//! level's `w:lvlRestart` says otherwise). +//! - `num` instances of one abstractNum share their counters, so a list carries on across +//! them and across paragraphs that aren't in a list. A `num` with a `w:startOverride` counts +//! on its own, starting at the override. +//! - Parent levels in a label (`%1.%2.`) are written as text: the model's labels number only +//! the paragraph's own level. + +use std::collections::HashMap; + +use designcraft_doc::{ListType, NumberStyle, ParaAttrs}; + +use crate::docx::El; + +/// Most `w:abstractNum` or `w:num` elements read; the rest are ignored. +pub(crate) const MAX_DEFS: usize = 10_000; +/// Word's list levels are 0–8. +const LEVELS: usize = 9; +/// Longest label text (`w:lvlText`) kept, in characters. +const MAX_LVL_TEXT: usize = 64; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Format { + #[default] + Bullet, + /// No number: the label is the level text alone. + Nothing, + Number(NumberStyle), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Suffix { + #[default] + Tab, + Space, + Nothing, +} + +#[derive(Clone, Debug, Default)] +struct Level { + format: Format, + text: String, + start: u32, + suffix: Suffix, + left: Option, + /// First-line indent in points (negative for a hanging indent). + first_line: Option, + /// `w:lvlRestart`: 0 = never restart, n = restart after level n (1-based) or a higher one. + restart: Option, + /// `w:isLgl`: parent levels in the label are written as decimal numbers. + legal: bool, + /// The font of the label (`w:rPr/w:rFonts`), which decides what a Symbol/Wingdings bullet is. + font: Option, + picture: bool, +} + +#[derive(Debug, Default)] +struct Num { + abstract_id: String, + /// `w:lvlOverride` per level: a start override and/or a replacement level. + start_override: [Option; LEVELS], + level_override: [Option; LEVELS], +} + +impl Num { + fn has_start_override(&self) -> bool { + self.start_override.iter().any(Option::is_some) + } +} + +/// A list paragraph: the paragraph attributes that make it one, and its number in Word. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ListPara { + pub(crate) attrs: ParaAttrs, + /// The paragraph's number at its level (also counted for bullets). + pub(crate) number: u32, +} + +/// The lists of a Word document and the numbering state while its paragraphs are read in order. +#[derive(Debug, Default)] +pub(crate) struct Numbering { + abstracts: HashMap; LEVELS]>, + nums: HashMap, + /// Current number per level, per counter key (an abstractNum, or a `num` with overrides). + counters: HashMap; LEVELS]>, +} + +fn level_index(e: &El) -> Option { + e.attr("ilvl").and_then(|v| v.trim().parse::().ok()).filter(|l| *l < LEVELS) +} + +fn parse_u32(v: Option) -> Option { + let v = v?; + let v = v.trim(); + // Negative starts count from 0; numbers too large for a u32 are clamped. + match v.parse::() { + Ok(n) => Some(u32::try_from(n.max(0)).unwrap_or(u32::MAX)), + Err(_) => None, + } +} + +fn number_style(fmt: &str) -> Format { + match fmt { + "bullet" => Format::Bullet, + "none" => Format::Nothing, + "decimalZero" => Format::Number(NumberStyle::ArabicLeadingZero), + "upperRoman" => Format::Number(NumberStyle::UpperRoman), + "lowerRoman" => Format::Number(NumberStyle::LowerRoman), + "upperLetter" => Format::Number(NumberStyle::UpperLetters), + "lowerLetter" => Format::Number(NumberStyle::LowerLetters), + "chicago" => Format::Number(NumberStyle::Symbols), + // decimal and every format the model has no style for (ordinal, cardinalText, + // decimalEnclosedCircle, the East Asian counting systems, …). + _ => Format::Number(NumberStyle::Arabic), + } +} + +fn parse_level(e: &El) -> Level { + let tw = |v: Option<&str>| v.and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0); + let ind = e.child("pPr").and_then(|p| p.child("ind")); + Level { + format: e.val("numFmt").map_or(Format::Number(NumberStyle::Arabic), |f| number_style(&f)), + text: e.child("lvlText").and_then(|t| t.attr("val")).unwrap_or_default().chars().take(MAX_LVL_TEXT).collect(), + start: parse_u32(e.val("start")).unwrap_or(1), + suffix: match e.val("suff").as_deref() { + Some("space") => Suffix::Space, + Some("nothing") => Suffix::Nothing, + _ => Suffix::Tab, + }, + left: ind.and_then(|i| tw(i.attr("left").or(i.attr("start")))), + first_line: ind.and_then(|i| tw(i.attr("firstLine")).or(tw(i.attr("hanging")).map(|h| -h))), + restart: parse_u32(e.val("lvlRestart")), + legal: e.on("isLgl") == Some(true), + font: e.child("rPr").and_then(|r| r.child("rFonts")).and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))).map(str::to_string), + picture: e.child("lvlPicBulletId").is_some(), + } +} + +/// The bullet Word shows for `text` in `font`. Symbol and Wingdings bullets are stored as +/// private-use characters (U+F0xx) or as the font's own code points; the common ones become +/// their Unicode look-alikes, unknown ones a plain bullet. +fn bullet_char(text: &str, font: Option<&str>) -> String { + let font = font.unwrap_or_default().to_ascii_lowercase(); + let symbol = font.contains("symbol"); + let wingdings = font.contains("wingdings"); + text.chars() + .map(|c| { + let code = u32::from(c); + let low = if (0xF000..=0xF0FF).contains(&code) { code - 0xF000 } else { code }; + if wingdings { + return match low { + 0x6C => '●', + 0x6E => '■', + 0x71 => '❑', + 0x76 => '❖', + 0xA7 => '▪', + 0xD8 => '➢', + 0xFC => '✓', + _ => '•', + }; + } + if symbol { + return match low { + 0x2D => '–', + 0x6F => '◦', + _ => '•', + }; + } + match c { + // Word's second-level bullet is a Courier New "o". + 'o' => '◦', + '\u{E000}'..='\u{F8FF}' => '•', + c => c, + } + }) + .filter(|c| *c != '\n' && *c != '\r') + .collect() +} + +impl Numbering { + /// Read `numbering.xml`'s root (`w:numbering`). + pub(crate) fn parse(root: &El) -> Self { + let mut n = Numbering::default(); + for e in root.els() { + match e.name.as_str() { + "abstractNum" if n.abstracts.len() < MAX_DEFS => { + let Some(id) = e.attr("abstractNumId") else { continue }; + let mut levels: [Option; LEVELS] = Default::default(); + for l in e.els().filter(|l| l.name == "lvl") { + if let Some(slot) = level_index(l).and_then(|i| levels.get_mut(i)) { + *slot = Some(parse_level(l)); + } + } + n.abstracts.insert(id.to_string(), levels); + } + "num" if n.nums.len() < MAX_DEFS => { + let (Some(id), Some(abstract_id)) = (e.attr("numId"), e.val("abstractNumId")) else { continue }; + let mut num = Num { abstract_id, ..Default::default() }; + for o in e.els().filter(|o| o.name == "lvlOverride") { + let Some(i) = level_index(o) else { continue }; + if let Some(slot) = num.start_override.get_mut(i) { + *slot = parse_u32(o.val("startOverride")); + } + if let (Some(slot), Some(l)) = (num.level_override.get_mut(i), o.child("lvl")) { + *slot = Some(parse_level(l)); + } + } + n.nums.insert(id.to_string(), num); + } + _ => {} + } + } + n + } + + /// The list formatting of the next paragraph with `w:numId` `num_id` at level `ilvl`, counting + /// it. `None` (a plain paragraph) for numId 0, an unknown list or level, or a level past 8. + pub(crate) fn next(&mut self, num_id: &str, ilvl: u32) -> Option { + let lv = usize::try_from(ilvl).ok().filter(|l| *l < LEVELS)?; + let num = self.nums.get(num_id)?; + let abs = self.abstracts.get(&num.abstract_id)?; + let level = |k: usize| num.level_override.get(k).and_then(Option::as_ref).or_else(|| abs.get(k).and_then(Option::as_ref)); + let def = level(lv)?; + let own = num.has_start_override(); + let key = if own { format!("n:{num_id}") } else { format!("a:{}", num.abstract_id) }; + let start = |k: usize| { + let o = if own { num.start_override.get(k).copied().flatten() } else { None }; + o.or_else(|| level(k).map(|l| l.start)).unwrap_or(1) + }; + let counters = self.counters.entry(key).or_default(); + let n = counters.get(lv).copied().flatten().map_or_else(|| start(lv), |v| v.saturating_add(1)); + if let Some(c) = counters.get_mut(lv) { + *c = Some(n); + } + for (m, c) in counters.iter_mut().enumerate().skip(lv + 1) { + let restart = match level(m).and_then(|l| l.restart) { + None => true, + Some(0) => false, + Some(r) => ilvl < r, + }; + if restart { + *c = None; + } + } + // Parent levels' current numbers, as text, for the label. + let parent = |k: usize| -> String { + let v = counters.get(k).copied().flatten().unwrap_or_else(|| start(k)); + match level(k).map(|l| l.format) { + Some(Format::Number(s)) if !def.legal => s.format(v), + Some(Format::Number(_)) => v.to_string(), + _ => String::new(), + } + }; + let label = |own_level: &str, escape: bool| -> String { + let mut out = String::new(); + let mut it = def.text.chars().peekable(); + while let Some(c) = it.next() { + if c == '%' + && let Some(k) = it.peek().and_then(|d| d.to_digit(10)).filter(|d| (1..=9).contains(d)) + { + it.next(); + let k = k as usize - 1; + if k == lv { + out.push_str(own_level); + } else { + let p = parent(k); + out.push_str(&if escape { p.replace('^', "^^") } else { p }); + } + } else if escape && c == '^' { + out.push_str("^^"); + } else if c != '\n' && c != '\r' { + out.push(c); + } + } + out + }; + // The model's defaults: `^t` after a number, a literal tab after a bullet. + let (number_sep, bullet_sep) = match def.suffix { + Suffix::Tab => ("^t", "\t"), + Suffix::Space => (" ", " "), + Suffix::Nothing => ("", ""), + }; + let mut a = ParaAttrs::default(); + match def.format { + Format::Number(style) => { + a.list_type = Some(ListType::Numbers); + a.number_style = Some(style); + a.number_expression = Some(format!("{}{number_sep}", label("^#", true))); + } + Format::Bullet => { + a.list_type = Some(ListType::Bullets); + a.bullet_char = Some(if def.picture { "•".into() } else { bullet_char(&def.text, def.font.as_deref()) }); + a.list_separator = Some(bullet_sep.into()); + } + Format::Nothing => { + a.list_type = Some(ListType::Bullets); + a.bullet_char = Some(label("", false)); + a.list_separator = Some(bullet_sep.into()); + } + } + a.left_indent = def.left; + a.first_line_indent = def.first_line; + Some(ListPara { attrs: a, number: n }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn symbol_and_wingdings_bullets_become_unicode() { + assert_eq!(bullet_char("\u{F0B7}", Some("Symbol")), "•"); + assert_eq!(bullet_char("\u{F0A7}", Some("Wingdings")), "▪"); + assert_eq!(bullet_char("o", Some("Courier New")), "◦"); + assert_eq!(bullet_char("–", None), "–"); + assert_eq!(bullet_char("\u{F076}", None), "•"); + } +} diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs index 22f79c513..ba9cb92e3 100644 --- a/crates/textimport/src/lib.rs +++ b/crates/textimport/src/lib.rs @@ -1,6 +1,6 @@ //! Text import (File › Place of text files): plain text, Word documents (`.docx`) and RTF become //! a frameless [`Story`] with paragraph and character styles, local bold/italic/underline/size, -//! footnotes (Word) and tables (Word), ready to flow into text frames. +//! footnotes, tables and bullets/numbering (Word), ready to flow into text frames. //! //! Word styles are imported by name (their font, size, bold/italic and space before/after become //! the style definition, like InDesign's "Use Word styles" import option). RTF keeps bold, @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] mod docx; +mod docx_numbering; pub mod export; mod rtf; pub mod tagged; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fc8fbc3a5..ae8f8c919 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -60,7 +60,7 @@ score below). | EPS | `.eps` | ✓ (PostScript) | ~ preview only (no interpreter) | | <1% | | SVG | `.svg` | ✓ | ✓ (usvg) | | <1% | | HEIC | `.heic` | ✓ | ✗ | | <1% | -| Word | `.docx` | ✓ styles, footnotes, tables, track changes, options | ✓ styles by name, footnotes, tables, tabs, breaks, moved text (#257), style mapping and conflicts | `.doc` not supported (InDesign reads it) | 2% | +| Word | `.docx` | ✓ styles, footnotes, tables, track changes, options | ✓ styles by name, footnotes, tables, tabs, breaks, moved text (#257), bullets and numbering from `numbering.xml` (#626; parent levels in a label are fixed text), style mapping and conflicts | `.doc` not supported (InDesign reads it) | 2% | | RTF | `.rtf` | ✓ | ✓ | | <1% | | Excel | `.xlsx`, `.xls` | ✓ | ~ `.xlsx` first sheet's used range | no sheet/range options; `.xls` ✗ | <1% | | Plain text, Markdown | `.txt`, `.md` | ✓ (txt) | ✓ | | <1% | From 270dd46b5062c8295a9103fedbe934ac83f99f27 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 21:06:42 +0000 Subject: [PATCH 032/200] Text: stroke behind fill (#619) A character attribute strokeBehindFill paints a text stroke under the fill at double width, so its full weight lies outside the letters and they keep their shape. Screen and PNG rendering, PDF export (stroke as outlines first, then the filled text, so the text is not duplicated; clipped to the outside of the glyphs when there is no fill), Create Outlines (outside- aligned stroke), the Properties panel and Character Style Options. IDML StrokeAlignment on CharacterStyleRange, CharacterStyle and ParagraphStyle imports and exports as the attribute (OutsideAlignment = on, CenterAlignment = off). --- ROADMAP.md | 3 +- crates/compose/src/lib.rs | 2 + crates/compose/src/shape.rs | 1 + crates/doc/src/attrs.rs | 17 ++++ crates/engine/src/cmd/paths.rs | 10 ++- crates/idml/src/export.rs | 3 + crates/idml/src/import.rs | 6 ++ crates/idml/src/tests.rs | 19 +++++ crates/pdf/src/tests.rs | 61 ++++++++++++++ crates/pdf/src/text.rs | 99 +++++++++++++++++------ crates/render/src/text.rs | 96 +++++++++++++--------- crates/render/tests/text_stroke_behind.rs | 63 +++++++++++++++ crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/panels/properties.rs | 4 + docs/typography-parity.md | 3 +- 15 files changed, 324 insertions(+), 66 deletions(-) create mode 100644 crates/render/tests/text_stroke_behind.rs diff --git a/ROADMAP.md b/ROADMAP.md index f826a3767..6106d978f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text stroke behind fill) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..2c3e7ccc0 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -50,6 +50,8 @@ pub struct RunStyle { pub stroke: String, pub stroke_tint: f32, pub stroke_weight: f64, + /// The stroke goes under the fill at double width, so its full weight shows outside the glyphs. + pub stroke_behind_fill: bool, pub underline: bool, pub strikethrough: bool, /// The underline and strikethrough bars (Underline / Strikethrough Options resolved). diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 1e1af40a7..a781a467d 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -138,6 +138,7 @@ impl StyleTable<'_> { stroke: p.stroke.clone(), stroke_tint: p.stroke_tint, stroke_weight: p.stroke_weight, + stroke_behind_fill: p.stroke_behind_fill, underline: p.underline, strikethrough: p.strikethrough, underline_rule: crate::Rule { diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index b99740e2c..6a0fe4d1f 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -735,6 +735,9 @@ attr_set! { stroke: String = "[None]".into(), stroke_tint: f32 = 1.0, stroke_weight: f64 = 1.0, + /// Stroke behind fill: the stroke is painted under the fill and lies wholly outside the + /// letters at its full weight, so the letters keep their shape. Off, it straddles the outline. + stroke_behind_fill: bool = false, capitalization: Capitalization = Capitalization::Normal, position: Position = Position::Normal, underline: bool = false, @@ -961,6 +964,20 @@ mod tests { assert_eq!(a.rule_below, None, "null clears the override"); } + #[test] + fn stroke_behind_fill_is_saved_only_when_set() { + assert!(!CharProps::default().stroke_behind_fill); + assert_eq!(serde_json::to_value(CharAttrs::default()).unwrap(), serde_json::json!({})); + let mut a = CharAttrs::default(); + a.set_json("strokeBehindFill", &serde_json::json!(true)).unwrap(); + let v = serde_json::to_value(&a).unwrap(); + assert_eq!(v, serde_json::json!({"strokeBehindFill": true})); + assert_eq!(serde_json::from_value::(v).unwrap(), a); + let mut p = CharProps::default(); + p.apply(&a); + assert!(p.stroke_behind_fill); + } + #[test] fn digits_map_to_their_script() { assert_eq!(Digits::Default.map('7', "Arabic"), '7'); diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..53eb4aff0 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -246,7 +246,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { let cs = designcraft_compose::compose_story(d, tf.story, &Default::default()); let Some(ft) = cs.frame(*id) else { continue }; // One path per text look (fill, stroke). - let mut runs: Vec<((String, f32, String, f32, f64), BezPath)> = Vec::new(); + let mut runs: Vec<((String, f32, String, f32, f64, bool), BezPath)> = Vec::new(); for l in &ft.lines { for g in l.glyphs.iter().filter(|g| g.visible) { let st = &cs.styles[g.style as usize]; @@ -256,7 +256,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { } let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); - let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight); + let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight, st.stroke_behind_fill); let k = match runs.iter().position(|r| r.0 == key) { Some(k) => k, None => { @@ -274,14 +274,16 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?; let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id)); let text_xf = d.text_xf(&it); - let make = |d: &mut Document, ((fill, ft, stroke, stt, sw), bp): ((String, f32, String, f32, f64), BezPath)| { + let make = |d: &mut Document, ((fill, ft, stroke, stt, sw, behind), bp): ((String, f32, String, f32, f64, bool), BezPath)| { let mut o = Item::new(ItemId(d.alloc()), it.layer, Shape::Path, PathData::from_bezpath(&bp)); o.xf = text_xf; o.fill = Fill { swatch: fill, tint: ft, ..Fill::none() }; o.stroke = if stroke == designcraft_color::swatch::NONE { Stroke::none() } else { - Stroke { swatch: stroke, tint: stt, weight: sw, ..Stroke::default() } + // A stroke behind the fill lies wholly outside the letters, as an outside-aligned stroke. + let align = if behind { designcraft_doc::StrokeAlign::Outside } else { designcraft_doc::StrokeAlign::Center }; + Stroke { swatch: stroke, tint: stt, weight: sw, align, ..Stroke::default() } }; o }; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..0800fa2b2 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1117,6 +1117,9 @@ impl<'a> Ex<'a> { if let Some(v) = a.stroke_weight { el.set("StrokeWeight", num(v)); } + if let Some(v) = a.stroke_behind_fill { + el.set("StrokeAlignment", if v { "OutsideAlignment" } else { "CenterAlignment" }); + } if let Some(v) = a.capitalization { el.set("Capitalization", names::caps_out(v)); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..4b8cbf45d 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1172,6 +1172,12 @@ impl<'r> Importer<'r> { } a.stroke_tint = tint(e.num("StrokeTint")); a.stroke_weight = e.num("StrokeWeight"); + // Text strokes are centred on the outline or lie outside it, behind the fill. + a.stroke_behind_fill = e.prop("StrokeAlignment").and_then(|v| match v.trim() { + "OutsideAlignment" => Some(true), + "CenterAlignment" => Some(false), + _ => None, + }); a.capitalization = e.prop("Capitalization").and_then(|v| names::caps_in(v.trim())); a.position = e.prop("Position").and_then(|v| names::position_in(v.trim())); a.underline = e.boolean("Underline"); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bdf..e4c6e9f92 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -842,6 +842,25 @@ fn cjk_character_attributes_import_from_independent_xml_and_round_trip() { assert_eq!(a.leading_model, b.leading_model); } +#[test] +fn text_stroke_alignment_imports_as_stroke_behind_fill_and_round_trips() { + let story = r#" + + Behind + Over + Plain + "#; + let flags = |d: &Document| -> Vec<(String, Option)> { + let st = d.stories.values().find(|s| s.text.contains("Behind")).unwrap(); + st.runs().map(|(r, f)| (st.text[r].to_string(), f.over.stroke_behind_fill)).filter(|(t, _)| !t.trim().is_empty()).collect() + }; + let want = vec![("Behind".to_string(), Some(true)), ("Over".to_string(), Some(false)), ("Plain".to_string(), None)]; + let doc = import_idml(&fixture_with_story(story)).unwrap(); + assert_eq!(flags(&doc), want); + let back = import_idml(&export_idml(&doc)).unwrap(); + assert_eq!(flags(&back), want); +} + #[test] fn warichu_imports_from_idml_and_round_trips() { let story = r#" diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d057..234e72a36 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -402,6 +402,67 @@ fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec) { (rec.0, rec.1) } +/// The page's paint operations in order: `fill`/`stroke` + ` text`/` path` and the stroke width. +fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + #[derive(Default)] + struct Rec(Vec<(String, f32)>); + impl Device<'_> for Rec { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, m: &PathDrawMode) { + self.0.push(match m { + PathDrawMode::Fill(_) => ("fill path".into(), 0.0), + PathDrawMode::Stroke(p) => ("stroke path".into(), p.line_width), + }); + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, m: &GlyphDrawMode) { + self.0.push(match m { + GlyphDrawMode::Fill => ("fill text".into(), 0.0), + GlyphDrawMode::Stroke(p) => ("stroke text".into(), p.line_width), + GlyphDrawMode::Invisible => ("invisible text".into(), 0.0), + }); + } + fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); + let cache = InterpreterCache::new(); + let page = &pdf.pages()[0]; + let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); + let mut rec = Rec::default(); + interpret_page(page, &mut ctx, &mut rec); + rec.0 +} + +#[test] +fn stroke_behind_fill_strokes_the_run_before_filling_it() { + let export = |behind: bool| { + let mut d = doc_with_text("I"); + let sid = *d.stories.keys().next().unwrap(); + d.story_mut(sid).unwrap().format_chars(0..1, |f| { + f.over.stroke = Some(designcraft_color::swatch::BLACK.into()); + f.over.stroke_weight = Some(4.0); + f.over.stroke_behind_fill = Some(behind); + }); + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let ops: Vec<(String, f32)> = paint_order(&bytes).into_iter().filter(|(op, w)| op.ends_with("text") || *w > 0.0).collect(); + (ops, extract_text(&bytes)[0].clone()) + }; + let (over, _) = export(false); + assert_eq!(over, [("fill text".to_string(), 0.0), ("stroke text".to_string(), 4.0)]); + let (behind, text) = export(true); + assert_eq!(behind, [("stroke path".to_string(), 8.0), ("fill text".to_string(), 0.0)], "the double-width stroke goes first"); + assert_eq!(text.trim(), "I", "the text is not duplicated"); +} + #[test] fn missing_glyphs_print_as_boxes() { // Source Serif 4 lacks 語: its .notdef box is drawn as a path, not as a .notdef glyph diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index bf59efded..0cd4770a9 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -300,16 +300,53 @@ impl Exporter<'_> { let hs = g0.sx / g0.sy; let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0); + // Stroke behind fill: a double-width stroke first, as outlines so the text is not + // duplicated, then the fill over its inner half. With no fill, the stroke is clipped + // to the outside of the glyphs instead. + let behind = st.stroke_behind_fill && stroke.is_some(); + let weight = if behind { st.stroke_weight * 2.0 } else { st.stroke_weight }; + let mut clipped = false; + let stroke = if behind { + let outlines = glyph_outlines(glyphs, size); + if fill.is_some() { + self.paint_outlines(s, &outlines, origin, None, stroke, weight); + None + } else { + clipped = self.push_outside_clip(s, &outlines, origin, weight); + stroke + } + } else { + stroke + }; // A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text. if g0.gid == 0 { - self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight); - return; - } - let Some(font) = self.font(&face) else { + self.paint_outlines(s, &glyph_outlines(glyphs, size), origin, fill, stroke, weight); + } else if let Some(font) = self.font(&face) { + self.text_run(s, glyphs, story, font, origin, size, hs, fill, stroke, weight); + } else { self.warn(format!("font `{} {}` could not be embedded; its text was drawn as outlines", face.family, face.style)); - self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight); - return; - }; + self.paint_outlines(s, &glyph_outlines(glyphs, size), origin, fill, stroke, weight); + } + if clipped { + s.pop(); + } + } + + /// A run as real text. + #[allow(clippy::too_many_arguments)] + fn text_run( + &mut self, + s: &mut Surface, + glyphs: &[PlacedGlyph], + story: &str, + font: Font, + origin: Affine, + size: f64, + hs: f64, + fill: Option, + stroke: Option, + stroke_weight: f64, + ) { let (text, ranges) = self.run_text(glyphs, story); let k = 1.0 / (hs * size); let kg: Vec = glyphs @@ -323,36 +360,24 @@ impl Exporter<'_> { .collect(); s.push_transform(&tf(origin)); s.set_fill(fill.map(|c| solid_fill(c, 1.0))); - s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() })); + s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() })); s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false); s.set_fill(None); s.set_stroke(None); s.pop(); } - /// Fallback for faces krilla can't embed: glyph outlines as paths. - #[allow(clippy::too_many_arguments)] - fn outline_run( + /// Glyph outlines as paths (faces krilla can't embed, missing glyphs, strokes behind fill). + fn paint_outlines( &mut self, s: &mut Surface, - glyphs: &[PlacedGlyph], + outlines: &BezPath, origin: Affine, - size: f64, fill: Option, stroke: Option, stroke_weight: f64, ) { - let db = FontDb::global(); - let g0 = &glyphs[0]; - let k = size / g0.face.upem; - let mut bp = BezPath::new(); - for g in glyphs { - let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k); - let mut o = (*db.outline(&g.face, g.gid)).clone(); - o.apply_affine(a); - bp.extend(o.iter()); - } - let Some(p) = to_path(&bp) else { return }; + let Some(p) = to_path(outlines) else { return }; s.push_transform(&tf(origin)); s.set_fill(fill.map(|c| solid_fill(c, 1.0))); s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() })); @@ -362,6 +387,17 @@ impl Exporter<'_> { s.pop(); } + /// Clip to everything outside the glyphs (a frame around them plus their outlines, + /// even-odd). False when there was nothing to clip with. + fn push_outside_clip(&mut self, s: &mut Surface, outlines: &BezPath, origin: Affine, weight: f64) -> bool { + let m = weight * 2.0 + 10.0; + let mut outside = outlines.bounding_box().inflate(m, m).to_path(0.1); + outside.extend(outlines.iter()); + let Some(p) = to_path(&(origin * outside)) else { return false }; + s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd); + true + } + /// One line of plain text in the bundled UI face (page information mark). pub(crate) fn plain_text(&mut self, s: &mut Surface, text: &str, at: (f64, f64), size: f64, color: krilla::color::Color) { let face = FontDb::global().face(designcraft_fonts::FALLBACK_FAMILY, "Regular"); @@ -391,3 +427,18 @@ impl Exporter<'_> { s.set_fill(None); } } + +/// A run's glyph outlines at `size`, relative to its first glyph's origin. +fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64) -> BezPath { + let db = FontDb::global(); + let mut bp = BezPath::new(); + let Some(g0) = glyphs.first() else { return bp }; + let k = size / g0.face.upem; + for g in glyphs { + let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k); + let mut o = (*db.outline(&g.face, g.gid)).clone(); + o.apply_affine(a); + bp.extend(o.iter()); + } + bp +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 734ec275e..57103a234 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -358,32 +358,9 @@ impl Renderer { let lg = &fg.lines[i]; self.stats.glyphs += lg.glyphs; for (si, bp) in &lg.runs { - if let Some(c) = fill_of(*si) { - let st = &cs.styles[*si as usize]; - let op = crate::overprints(f, &st.fill, st.fill_tint, false); - if op { - ctx.push_layer( - None, - Some(vello_cpu::peniko::BlendMode::new(vello_cpu::peniko::Mix::Multiply, vello_cpu::peniko::Compose::SrcOver)), - None, - None, - None, - ); - } - ctx.set_paint(c); - ctx.fill_path(bp); - if op { - ctx.pop_layer(); - } - } let st = &cs.styles[*si as usize]; - if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) - { - ctx.set_paint(color_of(&c, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); - ctx.stroke_path(bp); - } + let fill = fill_of(*si).map(|c| (c, crate::overprints(f, &st.fill, st.fill_tint, false))); + paint_glyphs(ctx, bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill); } } for &i in &shown { @@ -456,6 +433,62 @@ impl Renderer { } /// Kurbo stroke for a table edge. +/// A run's stroke colour, if it has one. +fn stroke_of(doc: &designcraft_doc::Document, st: &designcraft_compose::RunStyle) -> Option { + if st.stroke == designcraft_color::swatch::NONE { + return None; + } + doc.resolve_color(&st.stroke, st.stroke_tint).map(|c| color_of(&c, 1.0)) +} + +/// Paint glyph outlines: the fill (multiplied when it overprints), then the stroke over it. With +/// stroke behind fill a double-width stroke goes first and the fill covers its inner half; with +/// no fill to cover it, the glyph shapes are cut out of the stroke instead. +fn paint_glyphs( + ctx: &mut RenderContext, + bp: &BezPath, + fill: Option<(peniko::Color, bool)>, + stroke: Option, + weight: f64, + behind: bool, +) { + let paint_fill = |ctx: &mut RenderContext| { + let Some((c, overprint)) = fill else { return }; + if overprint { + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Multiply, peniko::Compose::SrcOver)), None, None, None); + } + ctx.set_paint(c); + ctx.fill_path(bp); + if overprint { + ctx.pop_layer(); + } + }; + let Some(sc) = stroke else { + paint_fill(ctx); + return; + }; + if !behind { + paint_fill(ctx); + ctx.set_paint(sc); + ctx.set_stroke(kurbo::Stroke::new(weight)); + ctx.stroke_path(bp); + return; + } + ctx.set_paint(sc); + ctx.set_stroke(kurbo::Stroke::new(weight * 2.0)); + if fill.is_some() { + ctx.stroke_path(bp); + paint_fill(ctx); + } else { + ctx.push_layer(None, None, None, None, None); + ctx.stroke_path(bp); + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)), None, None, None); + ctx.fill_path(bp); + ctx.pop_layer(); + ctx.pop_layer(); + } +} + fn cell_stroke(s: &designcraft_doc::CellStroke) -> kurbo::Stroke { let st = kurbo::Stroke::new(s.weight).with_caps(kurbo::Cap::Square); match &s.kind { @@ -484,17 +517,8 @@ impl Renderer { ctx.set_transform(f.view * xf); for (si, bp) in designcraft_compose::path_glyphs(cs, ft, path, pt) { let st = &cs.styles[si as usize]; - if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) { - ctx.set_paint(color_of(&c, 1.0)); - ctx.fill_path(&bp); - } - if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) - { - ctx.set_paint(color_of(&c, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); - ctx.stroke_path(&bp); - } + let fill = doc.resolve_color(&st.fill, st.fill_tint).map(|c| (color_of(&c, 1.0), false)); + paint_glyphs(ctx, &bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill); self.stats.glyphs += 1; } } diff --git a/crates/render/tests/text_stroke_behind.rs b/crates/render/tests/text_stroke_behind.rs new file mode 100644 index 000000000..103bee563 --- /dev/null +++ b/crates/render/tests/text_stroke_behind.rs @@ -0,0 +1,63 @@ +//! Stroke behind fill on text: the fill covers the inner half of a double-width stroke. +use designcraft_color::{Color, swatch::Swatch}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, ParaFormat, SpreadRef}; +use designcraft_geom::Rect; +use designcraft_render::{RenderOptions, Rendered, Renderer}; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; +const WEIGHT: f64 = 12.0; + +/// A 200 pt "I" filled with `fill`, stroked red at [`WEIGHT`] (none when `stroke` is false). +fn render(fill: &str, stroke: bool, behind: bool) -> Rendered { + let mut d = Document::new(&NewDocument { width: 400.0, height: 400.0, facing_pages: false, ..Default::default() }); + d.swatches.push(Swatch::color("Red", Color::rgb8(255, 0, 0))); + d.swatches.push(Swatch::color("Blue", Color::rgb8(0, 0, 255))); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 0.0, 400.0, 400.0), lid, "I", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..1, |f| { + f.over.size = Some(200.0); + f.over.fill = Some(fill.into()); + f.over.stroke = Some(if stroke { "Red" } else { designcraft_color::swatch::NONE }.into()); + f.over.stroke_weight = Some(WEIGHT); + f.over.stroke_behind_fill = Some(behind); + }); + let mut r = Renderer::new(); + r.threads = 0; + let cache = Cache::new(); + let cs = cache.get(&d, sid, None); + assert!(!cs.frames[0].lines.is_empty(), "the glyph is set"); + r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions { paper: false, background: Some(WHITE), ..Default::default() }).unwrap() +} + +/// The left edge of the stem on a row through the middle of the unstroked glyph. +fn stem_edge() -> (u32, u32) { + let img = render("Blue", false, false); + let blue: Vec<(u32, u32)> = (0..img.height).flat_map(|y| (0..img.width).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y) == BLUE).collect(); + assert!(!blue.is_empty(), "the glyph is drawn"); + let y0 = blue.iter().map(|p| p.1).min().unwrap(); + let y1 = blue.iter().map(|p| p.1).max().unwrap(); + let y = (y0 + y1) / 2; + let x = (0..img.width).find(|&x| img.pixel(x, y) == BLUE).unwrap(); + (x, y) +} + +#[test] +fn stroke_behind_fill_shows_only_outside_the_letter() { + let (x, y) = stem_edge(); + let inner = x + 3; // inside the outline, within the inner half of a centred stroke + let outer = x - 9; // outside the outline, beyond the outer half of a centred stroke + let over = render("Blue", true, false); + assert_eq!(over.pixel(inner, y), RED, "a centred stroke covers the fill's edge"); + assert_eq!(over.pixel(outer, y), WHITE, "a centred stroke reaches half its weight outside"); + let behind = render("Blue", true, true); + assert_eq!(behind.pixel(inner, y), BLUE, "the fill covers the stroke behind it"); + assert_eq!(behind.pixel(outer, y), RED, "the full stroke weight lies outside the letter"); + assert_eq!(behind.pixel(x - 15, y), WHITE, "and no further"); + let hollow = render(designcraft_color::swatch::NONE, true, true); + assert_eq!(hollow.pixel(inner, y), WHITE, "with no fill the letter stays empty"); + assert_eq!(hollow.pixel(outer, y), RED); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..cee849a99 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3060,6 +3060,9 @@ fn character_color(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharField crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); char_number(ui, d, cf, "strokeWeight", " pt", 1.0, (0.0, 800.0)); ui.end_row(); + ui.label(""); + char_check(ui, d, cf, "strokeBehindFill", crate::i18n::tr(lang, "Stroke Behind Fill")); + ui.end_row(); }); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..bc00d37b2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -973,6 +973,10 @@ fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { } ui.add_space(4.0); } + let mut behind = c["strokeBehindFill"].as_bool().unwrap_or(false); + if ui.checkbox(&mut behind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stroke Behind Fill"))).changed() { + let _ = app.run("type.char", json!({"attrs": {"strokeBehindFill": behind}})); + } } /// Character section: font family (with search), style, size/leading, kerning/tracking, •••. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 0339faf1e..093058395 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -1,6 +1,6 @@ # Typography and composition parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6) Typesetting is what InDesign is bought for. A feature can exist and still set text differently from InDesign: different line breaks, different page count, overset where InDesign fits. This @@ -40,6 +40,7 @@ Fidelity: **unmeasured** unless stated. | Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | | Kerning | Metrics, Optical, manual, none | Metrics, manual, none | ~ | **Optical treated as Metrics** (#181): imported display text sets wider and can overset | | Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | +| Character fill and stroke | fill, stroke, tint, weight, stroke aligned to centre or outside | fill, stroke, tints, weight, stroke behind fill (screen, PNG, PDF, IDML `StrokeAlignment`) | ~ | text strokes have no join or miter limit settings (#619 for stroke behind fill) | | OpenType features | ligatures, discretionary, swash, fractions, ordinals, figures, small caps, stylistic sets, positional forms | through harfrust | ✓ | | | Variable fonts | axes in Character panel | named instances and free axis values; PDF embeds the instance | ✓ | | | Leading | auto (120%), absolute, leading model | ✓ | ✓ | CJK leading models in cjk-typography.md | From bb7e81425ff86c76d99e1c09a6016245f4aa644b Mon Sep 17 00:00:00 2001 From: Jeffrey Thayne <36243577+thaynej@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:15:01 -0600 Subject: [PATCH 033/200] Links: never read a linked text file that isn't a regular file Checking a linked text file's status (every links.list, which the Links panel polls) compared sizes and then read the whole file, so a link to a FIFO or a device such as /dev/zero (size 0) never returned. Only regular files are read now, and no more than the Data Merge source limit (256 MiB): anything else shows as missing; a file over the limit shows as modified when its size changed and as tooLarge ("Too large to check") when it didn't. Update and Relink read through the same guard, and placing a file too large to check doesn't link it. Unix test: a FIFO, /dev/zero, a folder and an over-limit file each return promptly without being read (each call runs on a thread with a timeout, so a regression fails instead of hanging). Thanks @aiongg. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/links.rs | 94 +++++++++++++++++++++++-- crates/engine/src/cmd/place_text.rs | 8 +++ crates/ui-egui/src/panels/properties.rs | 1 + 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 1f9512953..e1c482a07 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -18,7 +18,7 @@ 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, edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { + 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))) }), cmd!( @@ -145,14 +145,26 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str { } } -/// Link status of a linked text file. +/// The largest linked text file read (the Data Merge source limit). Only regular files are read, +/// so a link to a FIFO or a device (e.g. `/dev/zero`) can't block or fill memory. +const MAX_TEXT_LINK_BYTES: u64 = super::datamerge::MAX_DATA_FILE_BYTES; + +/// Can a linked text file this size be checked and updated? +pub(crate) fn checkable_size(len: usize) -> bool { + len as u64 <= MAX_TEXT_LINK_BYTES +} + +/// 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 { #[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(_) => match std::fs::read(&l.path) { + 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", @@ -210,7 +222,23 @@ fn list(d: &Document) -> Vec { #[cfg(not(target_arch = "wasm32"))] fn read_text(path: &str) -> Result> { - std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + use std::io::Read; + let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}")); + let m = std::fs::metadata(path).map_err(err)?; + if !m.is_file() { + 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))); + if m.len() > MAX_TEXT_LINK_BYTES { + return Err(too_large()); + } + // At most one byte over the limit, in case the file grew since. + let mut b = Vec::new(); + std::fs::File::open(path).map_err(err)?.take(MAX_TEXT_LINK_BYTES + 1).read_to_end(&mut b).map_err(err)?; + if b.len() as u64 > MAX_TEXT_LINK_BYTES { + return Err(too_large()); + } + Ok(b) } #[cfg(target_arch = "wasm32")] @@ -712,3 +740,61 @@ mod text_link_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +#[cfg(unix)] +mod text_link_read_tests { + use std::time::Duration; + + use designcraft_doc::TextFileLink; + + use super::{MAX_TEXT_LINK_BYTES, read_text, text_status}; + + /// `f` on another thread: a call that blocks fails the test instead of hanging it. + fn promptly(f: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + // A timeout means the call blocked. + rx.recv_timeout(Duration::from_secs(10)).unwrap() + } + + 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)) + } + + 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())) + } + + #[test] + fn links_to_fifos_devices_folders_and_huge_files_are_not_read() { + let dir = std::env::temp_dir().join(format!("dc-text-link-read-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // A FIFO with no writer: reading it would block for ever. + let fifo = dir.join("pipe.docx"); + assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success()); + assert_eq!(status_of(&fifo, 0), "missing"); + assert!(read(&fifo).is_err()); + // A device that never ends, recorded with size 0 (as its metadata reports). + let zero = std::path::Path::new("/dev/zero"); + assert_eq!(status_of(zero, 0), "missing"); + assert!(read(zero).is_err()); + // A folder. + assert_eq!(status_of(&dir, 0), "missing"); + assert!(read(&dir).is_err()); + // Over the limit (sparse, so cheap): not read; the same size is reported as too large + // to check rather than OK, another size as modified. + let big = dir.join("big.txt"); + std::fs::File::create(&big).unwrap().set_len(MAX_TEXT_LINK_BYTES + 1).unwrap(); + assert_eq!(status_of(&big, MAX_TEXT_LINK_BYTES + 1), "tooLarge"); + assert_eq!(status_of(&big, 10), "modified"); + assert!(read(&big).unwrap_err().contains("larger than")); + assert!(!super::checkable_size(MAX_TEXT_LINK_BYTES as usize + 1), "placing it doesn't link it"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 698f38809..4d4577a5f 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -251,6 +251,11 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8], p let map = map_styles(&s.doc()?.doc, &mut imp, &map, conflict); // Create Links When Placing Text Files (only a file on disk can be linked). let link = path.filter(|_| p.get("link").and_then(Value::as_bool).unwrap_or(s.prefs.link_text_files)); + // A file too large for the Links panel to check isn't linked. + let (link, too_large) = match link { + Some(_) if !super::links::checkable_size(bytes.len()) => (None, true), + l => (l, false), + }; let autoflow_on = p.get("autoflow").and_then(Value::as_bool).unwrap_or(false); let st = s.doc()?; let caret = st.selection.text.filter(|t| t.cell.is_none()); @@ -260,6 +265,9 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8], p let page = p.get("page").and_then(Value::as_u64).map(|v| (v as usize).saturating_sub(1)); let rect = super::rect_param(p, "rect"); let mut warnings = imp.warnings.clone(); + if too_large { + warnings.push("not linked: the file is too large to keep linked".into()); + } s.edit(|d, sel| { add_styles(d, &imp, conflict); let sid = if let Some(t) = caret { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 7d683a8ff..3fc97c2ee 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1850,6 +1850,7 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { "missing" => ("\u{26A0} Missing", egui::Color32::from_rgb(0xe5, 0x4b, 0x4b)), "modified" => ("\u{25B2} Modified", egui::Color32::from_rgb(0xe8, 0xb3, 0x2c)), "embedded" => ("Embedded", t.text_dim), + "tooLarge" => ("Too large to check", egui::Color32::from_rgb(0xe8, 0xb3, 0x2c)), _ => ("OK", t.text_dim), }; crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, txt)).color(col).size(11.0)); From 260af3b5dfdc41476a8e9ba137db56ea0be468f4 Mon Sep 17 00:00:00 2001 From: fabian-co Date: Mon, 12 Oct 2026 17:45:45 -0500 Subject: [PATCH 034/200] 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 803c898d72fa63fa3ca9ef098fae449b1f60e99d Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 11:54:24 +0200 Subject: [PATCH 035/200] feat(render): Blend modes blend in CMYK on screen in a CMYK blend space Co-Authored-By: Claude Opus 5.5 --- crates/render/src/blendspace.rs | 312 ++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 73 +++++++- 2 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 crates/render/src/blendspace.rs diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs new file mode 100644 index 000000000..a8c22c81f --- /dev/null +++ b/crates/render/src/blendspace.rs @@ -0,0 +1,312 @@ +//! A CMYK transparency blend space on screen (Edit › Transparency Blend Space). +//! +//! InDesign blends a print document's transparency in CMYK: a blend mode works on each ink. So +//! Color Dodge with 100% K text turns what's under it to paper white where it has colour (black's +//! cyan, magenta and yellow are 0, their complements 1), while in RGB dodging with black changes +//! nothing. vello blends in RGB, so a spread whose top-level objects use separable blend modes is +//! composited here instead: +//! - the objects below each blended object are rendered on their own (without paper: blend modes +//! don't blend with the paper); +//! - the blended object is rendered alone with its own inks (the plate renders, so [Black] stays +//! 0/0/0/100), and blended with the backdrop pixel by pixel in the working CMYK (the backdrop +//! separated through the colour settings); +//! - the objects above are laid on top, and the result on the paper. +//! +//! PDF export needs none of this: the page group's CMYK blend space does it in the viewer. + +use designcraft_color::BlendMode as DcBlend; +use designcraft_compose::Cache; +use designcraft_doc::{Document, ItemId, SpreadRef}; +use designcraft_geom::{Affine, Rect}; + +use crate::{Placed, RenderOptions, Renderer}; + +/// Blend modes that blend channel by channel (Hue, Saturation, Color and Luminosity don't). +pub(crate) fn separable(b: DcBlend) -> bool { + !matches!(b, DcBlend::Normal | DcBlend::Hue | DcBlend::Saturation | DcBlend::Color | DcBlend::Luminosity) +} + +/// Top-level objects of spread `sr` in drawing order (layers back to front, a document spread's +/// parent items first in each layer, as `Renderer::draw_spread` draws them), each with whether +/// it blends in CMYK here (a spread object with a separable blend mode). +fn draw_order(doc: &Document, sr: SpreadRef, opts: &RenderOptions) -> Vec<(ItemId, bool)> { + let mut out: Vec<(ItemId, bool)> = Vec::new(); + let Some(sp) = doc.spread(sr) else { return out }; + for layer in doc.layers.iter().rev() { + if !layer.visible || opts.printing_only && !layer.printable { + continue; + } + if let SpreadRef::Doc(si) = sr { + let first = doc.first_page_of_spread(si); + for (pi, page) in sp.pages.iter().enumerate() { + if !page.show_parent_items { + continue; + } + let Some((ppi, _)) = doc.parent_page_for(first + pi) else { continue }; + let Some(parent) = doc.parents.get(ppi) else { continue }; + for it in parent.items.iter().filter(|it| it.layer == layer.id && !page.overridden.contains(&it.id)) { + if !out.iter().any(|(id, _)| *id == it.id) { + out.push((it.id, false)); + } + } + } + } + for it in sp.items.iter().filter(|it| it.layer == layer.id) { + let shown = !it.hidden && !opts.hidden.contains(&it.id) && !(opts.printing_only && it.nonprinting); + out.push((it.id, shown && separable(it.blend))); + } + } + out +} + +/// Separable blend function `b` on additive values (backdrop `cb`, source `cs`), as in PDF. +fn blend(b: DcBlend, cb: f32, cs: f32) -> f32 { + let multiply = |a: f32, c: f32| a * c; + let screen = |a: f32, c: f32| a + c - a * c; + let hard = |cb: f32, cs: f32| if cs <= 0.5 { multiply(cb, 2.0 * cs) } else { screen(cb, 2.0 * cs - 1.0) }; + match b { + DcBlend::Multiply => multiply(cb, cs), + DcBlend::Screen => screen(cb, cs), + DcBlend::Overlay => hard(cs, cb), + DcBlend::HardLight => hard(cb, cs), + DcBlend::SoftLight => { + if cs <= 0.5 { + cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb) + } else { + let d = if cb <= 0.25 { ((16.0 * cb - 12.0) * cb + 4.0) * cb } else { cb.sqrt() }; + cb + (2.0 * cs - 1.0) * (d - cb) + } + } + DcBlend::ColorDodge => { + if cb <= 0.0 { + 0.0 + } else if cs >= 1.0 { + 1.0 + } else { + (cb / (1.0 - cs)).min(1.0) + } + } + DcBlend::ColorBurn => { + if cb >= 1.0 { + 1.0 + } else if cs <= 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - cb) / cs).min(1.0) + } + } + DcBlend::Darken => cb.min(cs), + DcBlend::Lighten => cb.max(cs), + DcBlend::Difference => (cb - cs).abs(), + DcBlend::Exclusion => cb + cs - 2.0 * cb * cs, + _ => cs, + } +} + +/// Blend CMYK inks `source` (blend mode `mode`) over `backdrop`: a subtractive space blends the +/// complements (PDF), so the formulas see light, not ink. +pub(crate) fn blend_inks(mode: DcBlend, backdrop: [f32; 4], source: [f32; 4]) -> [f32; 4] { + let mut out = [0.0; 4]; + for i in 0..4 { + out[i] = 1.0 - blend(mode, 1.0 - backdrop[i], 1.0 - source[i]).clamp(0.0, 1.0); + } + out +} + +/// Working CMYK → sRGB through a 9⁴ table built once per colour settings (quadrilinear). +fn srgb_of(cmyk: [f32; 4]) -> [f32; 3] { + const N: usize = 9; + type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc>); + static LUT: std::sync::Mutex> = std::sync::Mutex::new(None); + let settings = designcraft_color::cms::active_settings(); + let table = { + let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); + match g.as_ref() { + Some((s, t)) if *s == settings => t.clone(), + _ => { + let cms = designcraft_color::cms::active(); + let step = |i: usize| i as f32 / (N - 1) as f32; + let t: Vec<[f32; 3]> = (0..N * N * N * N) + .map(|i| cms.cmyk_to_srgb([step(i / (N * N * N)), step(i / (N * N) % N), step(i / N % N), step(i % N)], false)) + .collect(); + let t = std::sync::Arc::new(t); + *g = Some((settings, t.clone())); + t + } + } + }; + let f = cmyk.map(|v| v.clamp(0.0, 1.0) * (N - 1) as f32); + let i = f.map(|v| (v as usize).min(N - 2)); + let d = [f[0] - i[0] as f32, f[1] - i[1] as f32, f[2] - i[2] as f32, f[3] - i[3] as f32]; + let mut out = [0.0f32; 3]; + for corner in 0..16usize { + let bit = |n: usize| corner >> (3 - n) & 1; + let w: f32 = (0..4).map(|n| if bit(n) == 1 { d[n] } else { 1.0 - d[n] }).product(); + if w == 0.0 { + continue; + } + let idx = (i[0] + bit(0)) * N * N * N + (i[1] + bit(1)) * N * N + (i[2] + bit(2)) * N + i[3] + bit(3); + if let Some(rgb) = table.get(idx) { + for k in 0..3 { + out[k] += rgb[k] * w; + } + } + } + out +} + +/// Premultiplied RGBA8 → straight 0..1 colour and alpha. +fn straight(px: &[u8]) -> ([f32; 3], f32) { + let a = px.get(3).copied().unwrap_or(0) as f32 / 255.0; + if a <= 0.0 { + return ([0.0; 3], 0.0); + } + let c = |i: usize| (px.get(i).copied().unwrap_or(0) as f32 / 255.0 / a).min(1.0); + ([c(0), c(1), c(2)], a) +} + +impl Renderer { + /// Composite spread `pl` again in the working CMYK where its top-level objects use separable + /// blend modes, over the pages' pixels of `pixels` (`w`×`h`, premultiplied, rendered with + /// `view`). Returns whether it did. + #[allow(clippy::too_many_arguments)] + pub(crate) fn cmyk_blend_spread( + &mut self, + doc: &Document, + cache: &Cache, + pl: &Placed, + w: u32, + h: u32, + view: Affine, + opts: &RenderOptions, + pixels: &mut [u8], + ) -> bool { + let order = draw_order(doc, pl.spread, opts); + if !order.iter().any(|(_, b)| *b) { + return false; + } + let Some(sp) = doc.spread(pl.spread) else { return false }; + // The pages' device pixels (the pasteboard keeps the RGB result). + let area = (view * pl.xf).transform_rect_bbox(sp.bounds()).intersect(Rect::new(0.0, 0.0, w as f64, h as f64)); + let (x0, y0) = (area.x0.floor().max(0.0) as u32, area.y0.floor().max(0.0) as u32); + let (x1, y1) = (area.x1.ceil().min(w as f64) as u32, area.y1.ceil().min(h as f64) as u32); + if x1 <= x0 || y1 <= y0 { + return false; + } + let (rw, rh) = (x1 - x0, y1 - y0); + let rview = Affine::translate((-(x0 as f64), -(y0 as f64))) * view; + let ids: Vec = order.iter().map(|(id, _)| *id).collect(); + let stats = self.stats; + // The spread's objects in `show` only (on paper or not), in a region-sized image. + let sub = |me: &mut Renderer, d: &Document, show: &[ItemId], paper: bool, plate: Option| { + let mut o = opts.clone(); + o.hidden.extend(ids.iter().filter(|i| !show.contains(i))); + o.paper = paper; + o.page_shadow = paper && opts.page_shadow; + o.background = if paper { opts.background } else { None }; + o.blend_space_view = false; + o.plate = plate; + me.render(d, cache, std::slice::from_ref(pl), rw, rh, rview, &o).pixels + }; + let first = order.iter().position(|(_, b)| *b).unwrap_or(order.len()); + let mut acc = sub(self, doc, ids.get(..first).unwrap_or(&[]), false, None); + let blended: Vec = order.iter().enumerate().filter(|(_, (_, b))| *b).map(|(i, _)| i).collect(); + for (j, &pos) in blended.iter().enumerate() { + let Some(&id) = ids.get(pos) else { continue }; + let Some(mode) = doc.item(id).map(|it| it.blend) else { continue }; + // The object alone, its blend mode left to us (opacity and effects stay). + let mut alone = doc.clone(); + if let Some(it) = alone.item_mut(id) { + it.blend = DcBlend::Normal; + } + let rgb = sub(self, &alone, &[id], false, None); + let cmy = sub(self, &alone, &[id], false, Some(4)); + let k = sub(self, &alone, &[id], false, Some(3)); + for p in (0..acc.len()).step_by(4) { + let (Some(s), Some(c), Some(kk)) = (rgb.get(p..p + 4), cmy.get(p..p + 4), k.get(p..p + 4)) else { continue }; + let (_, sa) = straight(s); + if sa <= 0.0 { + continue; + } + let Some(b) = acc.get(p..p + 4) else { continue }; + let (brgb, ba) = straight(b); + if ba <= 0.0 { + // Nothing below: the object as it is. + acc[p..p + 4].copy_from_slice(s); + continue; + } + let (cs, ca) = straight(c); + let (ks, _) = straight(kk); + let source = if ca > 0.0 { + [1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2], 1.0 - ks[0]] + } else { + crate::cmyk_lut(straight(s).0) + }; + let backdrop = crate::cmyk_lut(brgb); + let mixed = blend_inks(mode, backdrop, source); + // PDF compositing: the blend counts where the backdrop is. + let ar = ba + sa - ba * sa; + let t = sa / ar; + let mut out = [0.0f32; 4]; + for i in 0..4 { + let m = (1.0 - ba) * source[i] + ba * mixed[i]; + out[i] = (1.0 - t) * backdrop[i] + t * m; + } + let rgb_out = srgb_of(out); + let a8 = (ar * 255.0).round().clamp(0.0, 255.0); + for i in 0..3 { + acc[p + i] = (rgb_out[i].clamp(0.0, 1.0) * a8).round() as u8; + } + acc[p + 3] = a8 as u8; + } + // The objects up to the next blended one, laid on top. + let next = blended.get(j + 1).copied().unwrap_or(ids.len()); + if let Some(between) = ids.get(pos + 1..next).filter(|b| !b.is_empty()) { + let n = sub(self, doc, between, false, None); + over(&mut acc, &n); + } + } + // On the paper (and the canvas background). + let mut base = sub(self, doc, &[], opts.paper, None); + over(&mut base, &acc); + for y in 0..rh { + let (src, dst) = (((y * rw) * 4) as usize, (((y0 + y) * w + x0) * 4) as usize); + let len = (rw * 4) as usize; + if let (Some(s), Some(d)) = (base.get(src..src + len), pixels.get_mut(dst..dst + len)) { + d.copy_from_slice(s); + } + } + self.stats = stats; + true + } +} + +/// `top` over `dst` (premultiplied RGBA8, same size). +fn over(dst: &mut [u8], top: &[u8]) { + for (d, s) in dst.chunks_exact_mut(4).zip(top.chunks_exact(4)) { + let inv = 255 - s[3] as u32; + for i in 0..4 { + d[i] = (s[i] as u32 + (d[i] as u32 * inv + 127) / 255).min(255) as u8; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_dodge_with_black_whitens_coloured_inks_in_cmyk() { + // 100% K over a lilac (C 30 M 35): the inks go, K stays the backdrop's. + let r = blend_inks(DcBlend::ColorDodge, [0.3, 0.35, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]); + assert_eq!(r, [0.0, 0.0, 0.0, 0.0]); + // Multiply adds inks. + let r = blend_inks(DcBlend::Multiply, [0.3, 0.0, 0.0, 0.0], [0.0, 0.5, 0.0, 0.0]); + assert!((r[0] - 0.3).abs() < 1e-6 && (r[1] - 0.5).abs() < 1e-6); + // Multiplying by paper changes nothing; screening with 100% K clears the coloured inks too. + let near = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5); + assert!(near(blend_inks(DcBlend::Multiply, [0.2, 0.4, 0.6, 0.1], [0.0; 4]), [0.2, 0.4, 0.6, 0.1])); + assert!(near(blend_inks(DcBlend::Screen, [0.2, 0.4, 0.6, 0.1], [0.0, 0.0, 0.0, 1.0]), [0.0, 0.0, 0.0, 0.1])); + assert!(!separable(DcBlend::Normal) && !separable(DcBlend::Luminosity) && separable(DcBlend::ColorDodge)); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..9842b369c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -23,6 +23,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources}; pub use vello_cpu; +mod blendspace; pub mod damage; mod fx; @@ -143,6 +144,7 @@ pub struct RenderOptions { pub tag_markers: bool, /// Separations Preview: draw one process plate (0 = cyan … 3 = black) as grey ink coverage, /// from each object's own colour (CMYK as authored, others through the colour settings). + /// 4 (internal, the CMYK blend space): cyan, magenta and yellow coverage in red, green, blue. pub plate: Option, /// View › Display Performance. pub quality: DisplayQuality, @@ -330,6 +332,14 @@ impl Renderer { if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) { ctx.render(pm, &mut self.resources); } + // A CMYK blend space blends the spreads' blend modes in CMYK (blendspace.rs). + if opts.blend_space_view && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk && opts.plate.is_none() { + let stats = self.stats; + for pl in spreads { + self.cmyk_blend_spread(doc, cache, pl, w as u32, h as u32, view, opts, &mut pixels); + } + self.stats = stats; + } if opts.blend_space_view && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk && opts.plate.is_none() { let mut img = Rendered { width: w as u32, height: h as u32, pixels }; for pl in spreads { @@ -827,7 +837,11 @@ fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync: let k = 255.0 / p.a as f32; let rgb = [p.r, p.g, p.b].map(|v| (v as f32 * k / 255.0).min(1.0)); let cmyk = cmyk_lut(rgb); - let v = ((1.0 - cmyk[plate.min(3) as usize].clamp(0.0, 1.0)) * p.a as f32).round() as u8; + let ink = |i: usize| ((1.0 - cmyk[i].clamp(0.0, 1.0)) * p.a as f32).round() as u8; + if plate == 4 { + return vello_cpu::color::PremulRgba8 { r: ink(0), g: ink(1), b: ink(2), a: p.a }; + } + let v = ink(plate.min(3) as usize); vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: p.a } }) .collect(); @@ -860,8 +874,13 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { designcraft_color::Color::Cmyk { c, m, y, k } => [c, m, y, k], _ => c.to_cmyk_managed(designcraft_color::cms::Intent::RelativeColorimetric), }; - let v = ((1.0 - cmyk[p.min(3) as usize].clamp(0.0, 1.0)) * 255.0).round() as u8; - return peniko::Color::from_rgba8(v, v, v, (alpha.clamp(0.0, 1.0) * 255.0).round() as u8); + let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8; + let ink = |i: usize| ((1.0 - cmyk[i].clamp(0.0, 1.0)) * 255.0).round() as u8; + if p == 4 { + return peniko::Color::from_rgba8(ink(0), ink(1), ink(2), a); + } + let v = ink(p.min(3) as usize); + return peniko::Color::from_rgba8(v, v, v, a); } if RICH_BLACK.with(|r| r.get()) && let designcraft_color::Color::Cmyk { c: cc, m, y, k } = *c @@ -1336,6 +1355,54 @@ mod tests { assert!(rgb[1] > 245 && rgb[0] < 10, "{rgb:?}"); } + #[test] + fn blend_modes_blend_in_cmyk_on_screen_in_a_cmyk_blend_space() { + // A lilac (RGB) rectangle, and a 100% K one over its lower half and the paper below it, + // in Color Dodge: in CMYK the black clears the lilac's inks (white), over the paper it + // shows as it is; in RGB, dodging with black changes nothing. + let mut d = Document::new(&NewDocument::default()); + d.swatches.push(designcraft_color::swatch::Swatch::color("Lilac", designcraft_color::Color::rgb8(171, 154, 227))); + let lid = d.default_layer(); + let rect = |d: &mut Document, r: Rect, fill: &str| { + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + it.fill = Fill::swatch(fill); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + id + }; + rect(&mut d, Rect::new(100.0, 100.0, 300.0, 200.0), "Lilac"); + let black = rect(&mut d, Rect::new(150.0, 150.0, 250.0, 250.0), designcraft_color::swatch::BLACK); + d.item_mut(black).unwrap().blend = DcBlend::ColorDodge; + let cache = Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let px = |d: &Document, r: &mut Renderer, x: u32, y: u32| { + r.render_page(d, &cache, 0, 1.0, false, &RenderOptions { blend_space_view: true, ..Default::default() }).unwrap().pixel(x, y) + }; + let over_lilac = px(&d, &mut r, 200, 175); + assert!(over_lilac[..3].iter().all(|v| *v > 235), "dodged to white: {over_lilac:?}"); + let over_paper = px(&d, &mut r, 200, 225); + assert!(over_paper[..3].iter().all(|v| *v < 90), "black as it is: {over_paper:?}"); + let lilac = px(&d, &mut r, 120, 150); + assert!(lilac[2] > lilac[1] + 25, "the lilac beside it: {lilac:?}"); + // Inside a group, the group's blend mode. + let g = designcraft_doc::ItemId(d.alloc()); + let it = d.remove_item(black).unwrap(); + let mut grp = Item::new(g, lid, designcraft_doc::Shape::Group, Default::default()); + let mut inner = (*it).clone(); + inner.blend = DcBlend::Normal; + grp.content = Content::Group { items: vec![Arc::new(inner)] }; + grp.blend = DcBlend::ColorDodge; + d.insert_item(SpreadRef::Doc(0), grp, None).unwrap(); + let grouped = px(&d, &mut r, 200, 175); + assert!(grouped[..3].iter().all(|v| *v > 235), "{grouped:?}"); + // An RGB blend space: still lilac under the black (lightened a little: [Black] shows as a + // dark grey in RGB). + d.settings.blend_space = designcraft_doc::BlendSpace::Rgb; + let rgb = px(&d, &mut r, 200, 175); + assert!(rgb[2] > rgb[1] + 25 && rgb[1] < 220, "{rgb:?}"); + } + #[test] fn knockout_group_hides_its_own_objects() { let mut d = Document::new(&NewDocument::default()); From f51aba86a8c4ab8678dd681f92b548879361fe45 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 15:02:07 +0200 Subject: [PATCH 036/200] perf(render): look the colour tables up once per pass Co-Authored-By: Claude Opus 5.5 --- crates/render/src/blendspace.rs | 49 +++++++++++++++++------------- crates/render/src/lib.rs | 54 +++++++++++++++++++-------------- 2 files changed, 59 insertions(+), 44 deletions(-) diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index a8c22c81f..c7605a98f 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -113,28 +113,34 @@ pub(crate) fn blend_inks(mode: DcBlend, backdrop: [f32; 4], source: [f32; 4]) -> out } -/// Working CMYK → sRGB through a 9⁴ table built once per colour settings (quadrilinear). -fn srgb_of(cmyk: [f32; 4]) -> [f32; 3] { - const N: usize = 9; +const RGB_N: usize = 9; + +/// The working CMYK → sRGB table for the current colour settings (9⁴, built once per settings); +/// sample it with [`srgb_in`]. +fn srgb_table() -> std::sync::Arc> { + const N: usize = RGB_N; type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc>); static LUT: std::sync::Mutex> = std::sync::Mutex::new(None); let settings = designcraft_color::cms::active_settings(); - let table = { - let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); - match g.as_ref() { - Some((s, t)) if *s == settings => t.clone(), - _ => { - let cms = designcraft_color::cms::active(); - let step = |i: usize| i as f32 / (N - 1) as f32; - let t: Vec<[f32; 3]> = (0..N * N * N * N) - .map(|i| cms.cmyk_to_srgb([step(i / (N * N * N)), step(i / (N * N) % N), step(i / N % N), step(i % N)], false)) - .collect(); - let t = std::sync::Arc::new(t); - *g = Some((settings, t.clone())); - t - } + let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); + match g.as_ref() { + Some((s, t)) if *s == settings => t.clone(), + _ => { + let cms = designcraft_color::cms::active(); + let step = |i: usize| i as f32 / (N - 1) as f32; + let t: Vec<[f32; 3]> = (0..N * N * N * N) + .map(|i| cms.cmyk_to_srgb([step(i / (N * N * N)), step(i / (N * N) % N), step(i / N % N), step(i % N)], false)) + .collect(); + let t = std::sync::Arc::new(t); + *g = Some((settings, t.clone())); + t } - }; + } +} + +/// Quadrilinear lookup of `cmyk` in an [`srgb_table`]. +fn srgb_in(table: &[[f32; 3]], cmyk: [f32; 4]) -> [f32; 3] { + const N: usize = RGB_N; let f = cmyk.map(|v| v.clamp(0.0, 1.0) * (N - 1) as f32); let i = f.map(|v| (v as usize).min(N - 2)); let d = [f[0] - i[0] as f32, f[1] - i[1] as f32, f[2] - i[2] as f32, f[3] - i[3] as f32]; @@ -209,6 +215,7 @@ impl Renderer { me.render(d, cache, std::slice::from_ref(pl), rw, rh, rview, &o).pixels }; let first = order.iter().position(|(_, b)| *b).unwrap_or(order.len()); + let (to_cmyk, to_rgb) = (crate::cmyk_table(), srgb_table()); let mut acc = sub(self, doc, ids.get(..first).unwrap_or(&[]), false, None); let blended: Vec = order.iter().enumerate().filter(|(_, (_, b))| *b).map(|(i, _)| i).collect(); for (j, &pos) in blended.iter().enumerate() { @@ -240,9 +247,9 @@ impl Renderer { let source = if ca > 0.0 { [1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2], 1.0 - ks[0]] } else { - crate::cmyk_lut(straight(s).0) + crate::cmyk_in(&to_cmyk, straight(s).0) }; - let backdrop = crate::cmyk_lut(brgb); + let backdrop = crate::cmyk_in(&to_cmyk, brgb); let mixed = blend_inks(mode, backdrop, source); // PDF compositing: the blend counts where the backdrop is. let ar = ba + sa - ba * sa; @@ -252,7 +259,7 @@ impl Renderer { let m = (1.0 - ba) * source[i] + ba * mixed[i]; out[i] = (1.0 - t) * backdrop[i] + t * m; } - let rgb_out = srgb_of(out); + let rgb_out = srgb_in(&to_rgb, out); let a8 = (ar * 255.0).round().clamp(0.0, 255.0); for i in 0..3 { acc[p + i] = (rgb_out[i].clamp(0.0, 1.0) * a8).round() as u8; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9842b369c..5285a9bdc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -779,30 +779,35 @@ impl Renderer { } } -/// sRGB → working CMYK through a 17³ table built once per colour settings (trilinear). -fn cmyk_lut(rgb: [f32; 3]) -> [f32; 4] { - const N: usize = 17; +const CMYK_N: usize = 17; + +/// The sRGB → working CMYK table for the current colour settings (17³, built once per settings, +/// trilinear); +/// sample it with [`cmyk_in`]. Fetch it once for many pixels. +pub(crate) fn cmyk_table() -> std::sync::Arc> { + const N: usize = CMYK_N; type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc>); static LUT: std::sync::Mutex> = std::sync::Mutex::new(None); let settings = designcraft_color::cms::active_settings(); - let table = { - let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); - match g.as_ref() { - Some((s, t)) if *s == settings => t.clone(), - _ => { - let cms = designcraft_color::cms::active(); - let step = |i: usize| i as f32 / (N - 1) as f32; - let t: Vec<[f32; 4]> = (0..N * N * N) - .map(|i| { - cms.srgb_to_cmyk([step(i / (N * N)), step(i / N % N), step(i % N)], designcraft_color::cms::Intent::RelativeColorimetric) - }) - .collect(); - let t = std::sync::Arc::new(t); - *g = Some((settings, t.clone())); - t - } + let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); + match g.as_ref() { + Some((s, t)) if *s == settings => t.clone(), + _ => { + let cms = designcraft_color::cms::active(); + let step = |i: usize| i as f32 / (N - 1) as f32; + let t: Vec<[f32; 4]> = (0..N * N * N) + .map(|i| cms.srgb_to_cmyk([step(i / (N * N)), step(i / N % N), step(i % N)], designcraft_color::cms::Intent::RelativeColorimetric)) + .collect(); + let t = std::sync::Arc::new(t); + *g = Some((settings, t.clone())); + t } - }; + } +} + +/// Trilinear lookup of `rgb` in a [`cmyk_table`]. +pub(crate) fn cmyk_in(table: &[[f32; 4]], rgb: [f32; 3]) -> [f32; 4] { + const N: usize = CMYK_N; let f = rgb.map(|v| v.clamp(0.0, 1.0) * (N - 1) as f32); let i = f.map(|v| (v as usize).min(N - 2)); let d = [f[0] - i[0] as f32, f[1] - i[1] as f32, f[2] - i[2] as f32]; @@ -812,8 +817,10 @@ fn cmyk_lut(rgb: [f32; 3]) -> [f32; 4] { let w = (if a == 1 { d[0] } else { 1.0 - d[0] }) * (if b == 1 { d[1] } else { 1.0 - d[1] }) * (if cc == 1 { d[2] } else { 1.0 - d[2] }); ((i[0] + a) * N * N + (i[1] + b) * N + i[2] + cc, w) }) { - for k in 0..4 { - out[k] += table[corner][k] * w; + if let Some(t) = table.get(corner) { + for k in 0..4 { + out[k] += t[k] * w; + } } } out @@ -827,6 +834,7 @@ fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync: if let Some(v) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.get(&key).cloned()) { return v; } + let table = cmyk_table(); let data: Vec = pm .data() .iter() @@ -836,7 +844,7 @@ fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync: } let k = 255.0 / p.a as f32; let rgb = [p.r, p.g, p.b].map(|v| (v as f32 * k / 255.0).min(1.0)); - let cmyk = cmyk_lut(rgb); + let cmyk = cmyk_in(&table, rgb); let ink = |i: usize| ((1.0 - cmyk[i].clamp(0.0, 1.0)) * p.a as f32).round() as u8; if plate == 4 { return vello_cpu::color::PremulRgba8 { r: ink(0), g: ink(1), b: ink(2), a: p.a }; From eb89eef4ad7100cb6c20392b3e4dd0d5393f1043 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 15:11:10 +0200 Subject: [PATCH 037/200] docs: CMYK blending on screen Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/render/src/blendspace.rs | 8 ++------ docs/target-app-parity.md | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..9156b1968 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** · In a CMYK transparency blend space (print documents), blend modes blend in CMYK on screen as in InDesign. Color Dodge with 100% K text turns coloured areas under it white and leaves the paper alone, where RGB blending changed almost nothing. PDF export already did this. 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/blendspace.rs b/crates/render/src/blendspace.rs index c7605a98f..34e01a80f 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -52,7 +52,7 @@ fn draw_order(doc: &Document, sr: SpreadRef, opts: &RenderOptions) -> Vec<(ItemI } } for it in sp.items.iter().filter(|it| it.layer == layer.id) { - let shown = !it.hidden && !opts.hidden.contains(&it.id) && !(opts.printing_only && it.nonprinting); + let shown = !(it.hidden || opts.hidden.contains(&it.id) || opts.printing_only && it.nonprinting); out.push((it.id, shown && separable(it.blend))); } } @@ -244,11 +244,7 @@ impl Renderer { } let (cs, ca) = straight(c); let (ks, _) = straight(kk); - let source = if ca > 0.0 { - [1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2], 1.0 - ks[0]] - } else { - crate::cmyk_in(&to_cmyk, straight(s).0) - }; + let source = if ca > 0.0 { [1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2], 1.0 - ks[0]] } else { crate::cmyk_in(&to_cmyk, straight(s).0) }; let backdrop = crate::cmyk_in(&to_cmyk, brgb); let mixed = blend_inks(mode, backdrop, source); // PDF compositing: the blend counts where the backdrop is. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..4fb784456 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -315,7 +315,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. - Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported. - File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. -- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK). +- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK). Their separable blend modes blend in CMYK too (the object’s own inks against the backdrop separated through the colour settings), so Color Dodge with 100% K whitens coloured areas as in InDesign. - High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). - Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans). - Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images. From 048d8843bf939785d8b771f15ecc3f97430b4ec0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 18:06:30 +0200 Subject: [PATCH 038/200] perf(render): CMYK blending in two ink passes, for every blended object Separable blend formulas work channel by channel, so the spread is drawn twice through the ordinary renderer: C, M and Y complements into RGB, K into a second raster, with the existing transparency groups, masks, effects and parent-page placement. The inks are converted to sRGB once and put over the paper. Two passes regardless of how many objects blend; parent items, nested blends and effect blends (Multiply satin, shadows) blend in CMYK too, and the pass covers the whole view, so blending continues past the trim. An unchanged view is reused from a one-frame cache (64 MiB cap). Spreads with Hue, Saturation, Color or Luminosity keep the RGB-then-proof approximation. Effect blend layers sit outside their clips and offscreen filters, so they see the object's backdrop (RGB too). Co-Authored-By: Claude Opus 5.5 --- crates/render/src/blendspace.rs | 667 +++++++++++++++++++++----------- crates/render/src/fx.rs | 40 +- crates/render/src/lib.rs | 116 ++++-- crates/render/src/text.rs | 18 +- docs/target-app-parity.md | 2 +- 5 files changed, 561 insertions(+), 282 deletions(-) diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 34e01a80f..31de419a1 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -1,116 +1,234 @@ -//! A CMYK transparency blend space on screen (Edit › Transparency Blend Space). +//! CMYK transparency using two passes of the ordinary scene renderer. //! -//! InDesign blends a print document's transparency in CMYK: a blend mode works on each ink. So -//! Color Dodge with 100% K text turns what's under it to paper white where it has colour (black's -//! cyan, magenta and yellow are 0, their complements 1), while in RGB dodging with black changes -//! nothing. vello blends in RGB, so a spread whose top-level objects use separable blend modes is -//! composited here instead: -//! - the objects below each blended object are rendered on their own (without paper: blend modes -//! don't blend with the paper); -//! - the blended object is rendered alone with its own inks (the plate renders, so [Black] stays -//! 0/0/0/100), and blended with the backdrop pixel by pixel in the working CMYK (the backdrop -//! separated through the colour settings); -//! - the objects above are laid on top, and the result on the paper. +//! Separable PDF blend functions operate on the complements of the four inks. Render C/M/Y +//! complements into RGB and K into a second raster, keeping the existing transparency groups, +//! masks, parent-page placement and effects. Convert the final inks to sRGB once, then put the +//! transparent result over paper. Work is independent of the number of blended objects. //! -//! PDF export needs none of this: the page group's CMYK blend space does it in the viewer. +//! Nonseparable modes need a four-channel implementation: a spread containing one keeps the +//! existing RGB/proof approximation instead of applying RGB Hue/Color formulas to CMY channels. +use crate::{Frame, Placed, RenderOptions, Rendered, Renderer}; use designcraft_color::BlendMode as DcBlend; -use designcraft_compose::Cache; -use designcraft_doc::{Document, ItemId, SpreadRef}; +use designcraft_doc::{Document, Item, SpreadRef}; use designcraft_geom::{Affine, Rect}; +use vello_cpu::{RenderContext, peniko}; -use crate::{Placed, RenderOptions, Renderer}; - -/// Blend modes that blend channel by channel (Hue, Saturation, Color and Luminosity don't). -pub(crate) fn separable(b: DcBlend) -> bool { - !matches!(b, DcBlend::Normal | DcBlend::Hue | DcBlend::Saturation | DcBlend::Color | DcBlend::Luminosity) +/// At most one viewport, capped at 64 MiB. The snapshot shares the document's large Arcs; +/// comparisons use their identity without scanning text or image bytes. Keeping it prevents address reuse. +/// All document fields, colour settings and options participate, including parent pages, +/// stories, assets, layer visibility and font scope. `clear_caches` releases it too. +pub(crate) struct CachedView { + doc: Document, + settings: designcraft_color::cms::ColorSettings, + opts: RenderOptions, + spreads: Vec, + view: Affine, + image: Rendered, + stats: crate::FrameStats, + threads: u16, +} +impl CachedView { + pub(crate) const MAX_BYTES: usize = 64 << 20; + #[allow(clippy::too_many_arguments)] + pub(crate) fn matches( + &self, + doc: &Document, + spreads: &[Placed], + width: u32, + height: u32, + view: Affine, + opts: &RenderOptions, + threads: u16, + ) -> bool { + self.image.width == width.clamp(1, crate::MAX_SIDE) + && self.image.height == height.clamp(1, crate::MAX_SIDE) + && self.view == view + && self.opts == *opts + && self.spreads == spreads + && self.threads == threads + && self.settings == designcraft_color::cms::active_settings() + && same_document(&self.doc, doc) + } + pub(crate) fn image(&self) -> Rendered { + self.image.clone() + } + pub(crate) fn stats(&self) -> crate::FrameStats { + self.stats + } + pub(crate) fn new( + doc: &Document, + spreads: &[Placed], + view: Affine, + opts: &RenderOptions, + image: &Rendered, + stats: crate::FrameStats, + threads: u16, + ) -> Self { + Self { + doc: doc.clone(), + settings: designcraft_color::cms::active_settings(), + opts: opts.clone(), + spreads: spreads.to_vec(), + view, + image: image.clone(), + stats, + threads, + } + } +} +/// Compare every field, but keep large immutable data on an identity-only path. Exhaustive +/// destructuring makes a new document field require an explicit cache invalidation decision. +fn same_document(old: &Document, new: &Document) -> bool { + let Document { + spreads, + parents, + stories, + styles, + assets, + title, + settings, + layers, + swatches, + color_groups, + xml, + articles, + stroke_styles, + inks, + endnote_options, + endnote_story, + conditions, + sections, + hyperlinks, + data_merge, + bookmarks, + user_words, + hyphenation_exceptions, + toc, + text_variables, + footnote_options, + xref_formats, + index, + created, + modified, + next_id, + font_scope, + } = new; + let same_spreads = |a: &[std::sync::Arc], b: &[std::sync::Arc]| { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) + }; + same_spreads(&old.spreads, spreads) + && same_spreads(&old.parents, parents) + && std::sync::Arc::ptr_eq(&old.styles, styles) + && old.stories.len() == stories.len() + && old.stories.iter().zip(stories).all(|((a, x), (b, y))| a == b && std::sync::Arc::ptr_eq(x, y)) + && old.assets.len() == assets.len() + && old.assets.iter().zip(assets).all(|((a, x), (b, y))| a == b && std::sync::Arc::ptr_eq(x, y)) + && old.title == *title + && old.settings == *settings + && old.layers == *layers + && old.swatches == *swatches + && old.color_groups == *color_groups + && old.xml == *xml + && old.articles == *articles + && old.stroke_styles == *stroke_styles + && old.inks == *inks + && old.endnote_options == *endnote_options + && old.endnote_story == *endnote_story + && old.conditions == *conditions + && old.sections == *sections + && old.hyperlinks == *hyperlinks + && old.data_merge == *data_merge + && old.bookmarks == *bookmarks + && old.user_words == *user_words + && old.hyphenation_exceptions == *hyphenation_exceptions + && old.toc == *toc + && old.text_variables == *text_variables + && old.footnote_options == *footnote_options + && old.xref_formats == *xref_formats + && old.index == *index + && old.created == *created + && old.modified == *modified + && old.next_id == *next_id + && old.font_scope == *font_scope } -/// Top-level objects of spread `sr` in drawing order (layers back to front, a document spread's -/// parent items first in each layer, as `Renderer::draw_spread` draws them), each with whether -/// it blends in CMYK here (a spread object with a separable blend mode). -fn draw_order(doc: &Document, sr: SpreadRef, opts: &RenderOptions) -> Vec<(ItemId, bool)> { - let mut out: Vec<(ItemId, bool)> = Vec::new(); - let Some(sp) = doc.spread(sr) else { return out }; - for layer in doc.layers.iter().rev() { - if !layer.visible || opts.printing_only && !layer.printable { - continue; +fn supported(mode: DcBlend) -> bool { + !matches!(mode, DcBlend::Hue | DcBlend::Saturation | DcBlend::Color | DcBlend::Luminosity) +} +#[derive(Clone, Copy, PartialEq, Eq)] +enum Pipeline { + Rgb, + Cmyk, + Proof, +} + +/// Whether a visible spread needs, and can use, the ink-channel pipeline. +pub(crate) fn uses_cmyk(doc: &Document, sr: SpreadRef, opts: &RenderOptions) -> bool { + pipeline(doc, sr, opts) == Pipeline::Cmyk +} +/// The legacy approximation is retained only for unsupported nonseparable blends. +pub(crate) fn uses_rgb_proof(doc: &Document, sr: SpreadRef, opts: &RenderOptions) -> bool { + pipeline(doc, sr, opts) == Pipeline::Proof +} + +fn pipeline(doc: &Document, sr: SpreadRef, opts: &RenderOptions) -> Pipeline { + if !opts.blend_space_view || opts.plate.is_some() || doc.settings.blend_space != designcraft_doc::BlendSpace::Cmyk { + return Pipeline::Rgb; + } + let Some(sp) = doc.spread(sr) else { return Pipeline::Rgb }; + let mut transparent = false; + let mut inspect = |item: &Item| { + if !doc.layer(item.layer).is_some_and(|l| l.visible && (!opts.printing_only || l.printable)) { + return true; } - if let SpreadRef::Doc(si) = sr { - let first = doc.first_page_of_spread(si); - for (pi, page) in sp.pages.iter().enumerate() { - if !page.show_parent_items { + inspect_item(doc, item, opts, 0, &mut transparent) + }; + for item in &sp.items { + if !inspect(item) { + return Pipeline::Proof; + } + } + if let SpreadRef::Doc(si) = sr { + let first = doc.first_page_of_spread(si); + for (pi, page) in sp.pages.iter().enumerate() { + if !page.show_parent_items { + continue; + } + let Some((ppi, ppage)) = doc.parent_page_for(first.saturating_add(pi)) else { continue }; + let Some(parent) = doc.parents.get(ppi) else { continue }; + for item in &parent.items { + if page.overridden.contains(&item.id) || parent.pages.len() > 1 && parent.page_at_x(item.bounds().center().x) != Some(ppage) { continue; } - let Some((ppi, _)) = doc.parent_page_for(first + pi) else { continue }; - let Some(parent) = doc.parents.get(ppi) else { continue }; - for it in parent.items.iter().filter(|it| it.layer == layer.id && !page.overridden.contains(&it.id)) { - if !out.iter().any(|(id, _)| *id == it.id) { - out.push((it.id, false)); - } + if !inspect(item) { + return Pipeline::Proof; } } } - for it in sp.items.iter().filter(|it| it.layer == layer.id) { - let shown = !(it.hidden || opts.hidden.contains(&it.id) || opts.printing_only && it.nonprinting); - out.push((it.id, shown && separable(it.blend))); - } } - out + if transparent { Pipeline::Cmyk } else { Pipeline::Rgb } } -/// Separable blend function `b` on additive values (backdrop `cb`, source `cs`), as in PDF. -fn blend(b: DcBlend, cb: f32, cs: f32) -> f32 { - let multiply = |a: f32, c: f32| a * c; - let screen = |a: f32, c: f32| a + c - a * c; - let hard = |cb: f32, cs: f32| if cs <= 0.5 { multiply(cb, 2.0 * cs) } else { screen(cb, 2.0 * cs - 1.0) }; - match b { - DcBlend::Multiply => multiply(cb, cs), - DcBlend::Screen => screen(cb, cs), - DcBlend::Overlay => hard(cs, cb), - DcBlend::HardLight => hard(cb, cs), - DcBlend::SoftLight => { - if cs <= 0.5 { - cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb) - } else { - let d = if cb <= 0.25 { ((16.0 * cb - 12.0) * cb + 4.0) * cb } else { cb.sqrt() }; - cb + (2.0 * cs - 1.0) * (d - cb) - } - } - DcBlend::ColorDodge => { - if cb <= 0.0 { - 0.0 - } else if cs >= 1.0 { - 1.0 - } else { - (cb / (1.0 - cs)).min(1.0) - } - } - DcBlend::ColorBurn => { - if cb >= 1.0 { - 1.0 - } else if cs <= 0.0 { - 0.0 - } else { - 1.0 - ((1.0 - cb) / cs).min(1.0) - } - } - DcBlend::Darken => cb.min(cs), - DcBlend::Lighten => cb.max(cs), - DcBlend::Difference => (cb - cs).abs(), - DcBlend::Exclusion => cb + cs - 2.0 * cb * cs, - _ => cs, +/// Bounded traversal with no item-count-sized temporary allocation, including anchored items. +fn inspect_item(doc: &Document, it: &Item, opts: &RenderOptions, depth: usize, transparent: &mut bool) -> bool { + if it.hidden || opts.hidden.contains(&it.id) || opts.printing_only && it.nonprinting { + return true; } -} - -/// Blend CMYK inks `source` (blend mode `mode`) over `backdrop`: a subtractive space blends the -/// complements (PDF), so the formulas see light, not ink. -pub(crate) fn blend_inks(mode: DcBlend, backdrop: [f32; 4], source: [f32; 4]) -> [f32; 4] { - let mut out = [0.0; 4]; - for i in 0..4 { - out[i] = 1.0 - blend(mode, 1.0 - backdrop[i], 1.0 - source[i]).clamp(0.0, 1.0); + if depth >= 64 || !supported(it.blend) { + return false; } - out + *transparent |= it.opacity < 1.0 || it.blend != DcBlend::Normal || it.effects.any() || it.knockout; + if let Some(text) = it.text_frame() + && let Some(story) = doc.story(text.story) + { + for object in &story.objects { + if !inspect_item(doc, &object.item, opts, depth + 1, transparent) { + return false; + } + } + } + it.shown_children().all(|child| inspect_item(doc, child, opts, depth + 1, transparent)) } const RGB_N: usize = 9; @@ -161,155 +279,242 @@ fn srgb_in(table: &[[f32; 3]], cmyk: [f32; 4]) -> [f32; 3] { out } -/// Premultiplied RGBA8 → straight 0..1 colour and alpha. -fn straight(px: &[u8]) -> ([f32; 3], f32) { - let a = px.get(3).copied().unwrap_or(0) as f32 / 255.0; - if a <= 0.0 { - return ([0.0; 3], 0.0); - } - let c = |i: usize| (px.get(i).copied().unwrap_or(0) as f32 / 255.0 / a).min(1.0); - ([c(0), c(1), c(2)], a) -} - impl Renderer { - /// Composite spread `pl` again in the working CMYK where its top-level objects use separable - /// blend modes, over the pages' pixels of `pixels` (`w`×`h`, premultiplied, rendered with - /// `view`). Returns whether it did. - #[allow(clippy::too_many_arguments)] - pub(crate) fn cmyk_blend_spread( - &mut self, - doc: &Document, - cache: &Cache, - pl: &Placed, - w: u32, - h: u32, - view: Affine, - opts: &RenderOptions, - pixels: &mut [u8], - ) -> bool { - let order = draw_order(doc, pl.spread, opts); - if !order.iter().any(|(_, b)| *b) { - return false; - } - let Some(sp) = doc.spread(pl.spread) else { return false }; - // The pages' device pixels (the pasteboard keeps the RGB result). - let area = (view * pl.xf).transform_rect_bbox(sp.bounds()).intersect(Rect::new(0.0, 0.0, w as f64, h as f64)); - let (x0, y0) = (area.x0.floor().max(0.0) as u32, area.y0.floor().max(0.0) as u32); - let (x1, y1) = (area.x1.ceil().min(w as f64) as u32, area.y1.ceil().min(h as f64) as u32); - if x1 <= x0 || y1 <= y0 { - return false; - } - let (rw, rh) = (x1 - x0, y1 - y0); - let rview = Affine::translate((-(x0 as f64), -(y0 as f64))) * view; - let ids: Vec = order.iter().map(|(id, _)| *id).collect(); + /// Two channel passes across the viewport, including bleed and pasteboard. Paper has + /// already been painted in `ctx`; it must never be a blend backdrop. + pub(crate) fn draw_cmyk_spread(&mut self, ctx: &mut RenderContext, f: &Frame, sr: SpreadRef) { + let (w, h) = (ctx.width(), ctx.height()); let stats = self.stats; - // The spread's objects in `show` only (on paper or not), in a region-sized image. - let sub = |me: &mut Renderer, d: &Document, show: &[ItemId], paper: bool, plate: Option| { - let mut o = opts.clone(); - o.hidden.extend(ids.iter().filter(|i| !show.contains(i))); - o.paper = paper; - o.page_shadow = paper && opts.page_shadow; - o.background = if paper { opts.background } else { None }; - o.blend_space_view = false; - o.plate = plate; - me.render(d, cache, std::slice::from_ref(pl), rw, rh, rview, &o).pixels + let mut pass = |plate| { + let opts = + RenderOptions { paper: false, background: None, page_shadow: false, blend_space_view: false, plate: Some(plate), ..f.opts.clone() }; + crate::PLATE.with(|p| p.set(opts.plate)); + // Render filter layers directly in both ink passes, keeping their coverage on + // the same single-threaded path even when the outer viewport uses render workers. + let mut off = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + self.draw_spread(&mut off, &Frame { opts: &opts, mt: false, ..*f }, sr); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + #[cfg(test)] + { + self.cmyk_passes += 1; + } + pm }; - let first = order.iter().position(|(_, b)| *b).unwrap_or(order.len()); - let (to_cmyk, to_rgb) = (crate::cmyk_table(), srgb_table()); - let mut acc = sub(self, doc, ids.get(..first).unwrap_or(&[]), false, None); - let blended: Vec = order.iter().enumerate().filter(|(_, (_, b))| *b).map(|(i, _)| i).collect(); - for (j, &pos) in blended.iter().enumerate() { - let Some(&id) = ids.get(pos) else { continue }; - let Some(mode) = doc.item(id).map(|it| it.blend) else { continue }; - // The object alone, its blend mode left to us (opacity and effects stay). - let mut alone = doc.clone(); - if let Some(it) = alone.item_mut(id) { - it.blend = DcBlend::Normal; - } - let rgb = sub(self, &alone, &[id], false, None); - let cmy = sub(self, &alone, &[id], false, Some(4)); - let k = sub(self, &alone, &[id], false, Some(3)); - for p in (0..acc.len()).step_by(4) { - let (Some(s), Some(c), Some(kk)) = (rgb.get(p..p + 4), cmy.get(p..p + 4), k.get(p..p + 4)) else { continue }; - let (_, sa) = straight(s); - if sa <= 0.0 { - continue; - } - let Some(b) = acc.get(p..p + 4) else { continue }; - let (brgb, ba) = straight(b); - if ba <= 0.0 { - // Nothing below: the object as it is. - acc[p..p + 4].copy_from_slice(s); - continue; - } - let (cs, ca) = straight(c); - let (ks, _) = straight(kk); - let source = if ca > 0.0 { [1.0 - cs[0], 1.0 - cs[1], 1.0 - cs[2], 1.0 - ks[0]] } else { crate::cmyk_in(&to_cmyk, straight(s).0) }; - let backdrop = crate::cmyk_in(&to_cmyk, brgb); - let mixed = blend_inks(mode, backdrop, source); - // PDF compositing: the blend counts where the backdrop is. - let ar = ba + sa - ba * sa; - let t = sa / ar; - let mut out = [0.0f32; 4]; - for i in 0..4 { - let m = (1.0 - ba) * source[i] + ba * mixed[i]; - out[i] = (1.0 - t) * backdrop[i] + t * m; - } - let rgb_out = srgb_in(&to_rgb, out); - let a8 = (ar * 255.0).round().clamp(0.0, 255.0); - for i in 0..3 { - acc[p + i] = (rgb_out[i].clamp(0.0, 1.0) * a8).round() as u8; - } - acc[p + 3] = a8 as u8; - } - // The objects up to the next blended one, laid on top. - let next = blended.get(j + 1).copied().unwrap_or(ids.len()); - if let Some(between) = ids.get(pos + 1..next).filter(|b| !b.is_empty()) { - let n = sub(self, doc, between, false, None); - over(&mut acc, &n); + let mut cmy = pass(4); + let black = pass(3); + crate::PLATE.with(|p| p.set(f.opts.plate)); + self.stats.items = (self.stats.items - stats.items) / 2 + stats.items; + self.stats.glyphs = (self.stats.glyphs - stats.glyphs) / 2 + stats.glyphs; + let table = srgb_table(); + for (c, k) in cmy.data_mut().iter_mut().zip(black.data()) { + if c.a == 0 { + continue; } + let a = c.a as f32; + let inks = [1.0 - c.r as f32 / a, 1.0 - c.g as f32 / a, 1.0 - c.b as f32 / a, 1.0 - k.r as f32 / a]; + let rgb = if f.opts.rich_black && inks[0..3].iter().all(|v| *v <= 1e-3) && inks[3] >= 0.999 { [0.0; 3] } else { srgb_in(&table, inks) }; + let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * a).round() as u8); + (c.r, c.g, c.b) = (r, g, b); } - // On the paper (and the canvas background). - let mut base = sub(self, doc, &[], opts.paper, None); - over(&mut base, &acc); - for y in 0..rh { - let (src, dst) = (((y * rw) * 4) as usize, (((y0 + y) * w + x0) * 4) as usize); - let len = (rw * 4) as usize; - if let (Some(s), Some(d)) = (base.get(src..src + len), pixels.get_mut(dst..dst + len)) { - d.copy_from_slice(s); - } - } - self.stats = stats; - true - } -} - -/// `top` over `dst` (premultiplied RGBA8, same size). -fn over(dst: &mut [u8], top: &[u8]) { - for (d, s) in dst.chunks_exact_mut(4).zip(top.chunks_exact(4)) { - let inv = 255 - s[3] as u32; - for i in 0..4 { - d[i] = (s[i] as u32 + (d[i] as u32 * inv + 127) / 255).min(255) as u8; - } + ctx.set_transform(Affine::IDENTITY); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(cmy)), sampler: peniko::ImageSampler::default() }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64)); } } #[cfg(test)] mod tests { use super::*; + use designcraft_compose::Cache; + use designcraft_doc::{Content, Fill, ItemId, Shape, build::NewDocument}; + use std::sync::Arc; + + fn document() -> Document { + let mut doc = Document::new(&NewDocument { width: 160.0, height: 120.0, facing_pages: false, ..Default::default() }); + doc.swatches.push(designcraft_color::swatch::Swatch::color("Lilac", designcraft_color::Color::rgb8(171, 154, 227))); + doc + } + fn rectangle(doc: &mut Document, sr: SpreadRef, rect: Rect, fill: &str, blend: DcBlend) -> ItemId { + let id = ItemId(doc.alloc()); + let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(rect)); + item.fill = Fill::swatch(fill); + item.blend = blend; + doc.insert_item(sr, item, None).unwrap(); + id + } + fn draw(doc: &Document, renderer: &mut Renderer) -> Rendered { + renderer.render( + doc, + &Cache::new(), + &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], + 200, + 140, + Affine::translate((30.0, 10.0)), + &RenderOptions { blend_space_view: true, background: Some([180, 180, 180, 255]), ..Default::default() }, + ) + } + fn dodge(doc: &mut Document, sr: SpreadRef) -> ItemId { + rectangle(doc, sr, Rect::new(-25.0, 10.0, 130.0, 70.0), "Lilac", DcBlend::Normal); + rectangle(doc, sr, Rect::new(-20.0, 25.0, 100.0, 100.0), designcraft_color::swatch::BLACK, DcBlend::ColorDodge) + } + fn white(pixel: [u8; 4]) { + assert!(pixel[..3].iter().all(|v| *v > 235), "{pixel:?}"); + } #[test] - fn color_dodge_with_black_whitens_coloured_inks_in_cmyk() { - // 100% K over a lilac (C 30 M 35): the inks go, K stays the backdrop's. - let r = blend_inks(DcBlend::ColorDodge, [0.3, 0.35, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]); - assert_eq!(r, [0.0, 0.0, 0.0, 0.0]); - // Multiply adds inks. - let r = blend_inks(DcBlend::Multiply, [0.3, 0.0, 0.0, 0.0], [0.0, 0.5, 0.0, 0.0]); - assert!((r[0] - 0.3).abs() < 1e-6 && (r[1] - 0.5).abs() < 1e-6); - // Multiplying by paper changes nothing; screening with 100% K clears the coloured inks too. - let near = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5); - assert!(near(blend_inks(DcBlend::Multiply, [0.2, 0.4, 0.6, 0.1], [0.0; 4]), [0.2, 0.4, 0.6, 0.1])); - assert!(near(blend_inks(DcBlend::Screen, [0.2, 0.4, 0.6, 0.1], [0.0, 0.0, 0.0, 1.0]), [0.0, 0.0, 0.0, 0.1])); - assert!(!separable(DcBlend::Normal) && !separable(DcBlend::Luminosity) && separable(DcBlend::ColorDodge)); + fn cmyk_nested_blends_match_top_level_blends() { + let mut doc = document(); + let black = dodge(&mut doc, SpreadRef::Doc(0)); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let reference = draw(&doc, &mut renderer); + let child = doc.remove_item(black).unwrap(); + let mut group = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Group, Default::default()); + group.content = Content::Group { items: vec![child] }; + // A second group ensures the preflight and the rendering both follow nesting. + let mut outer = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Group, Default::default()); + outer.content = Content::Group { items: vec![Arc::new(group)] }; + doc.insert_item(SpreadRef::Doc(0), outer, None).unwrap(); + let nested = draw(&doc, &mut renderer); + white(nested.pixel(70, 50)); + assert_eq!(nested.pixels, reference.pixels); + } + + #[test] + fn cmyk_parent_blends_and_overrides_follow_parent_visibility() { + let mut doc = document(); + let black = dodge(&mut doc, SpreadRef::Parent(0)); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let shown = draw(&doc, &mut renderer); + white(shown.pixel(70, 50)); + Arc::make_mut(&mut doc.spreads[0]).pages[0].overridden.push(black); + let overridden = draw(&doc, &mut renderer); + assert_ne!(shown.pixel(70, 50), overridden.pixel(70, 50)); + Arc::make_mut(&mut doc.spreads[0]).pages[0].show_parent_items = false; + let hidden = draw(&doc, &mut renderer); + assert_eq!(hidden.pixel(70, 50), [255; 4]); + } + + #[test] + fn cmyk_blending_continues_across_trim_and_keeps_paper_out_of_backdrop() { + let mut doc = document(); + dodge(&mut doc, SpreadRef::Doc(0)); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let image = draw(&doc, &mut renderer); + // x=30 is the left trim. Identical overlap on either side must have identical inks. + white(image.pixel(20, 50)); + assert_eq!(image.pixel(20, 50), image.pixel(40, 50)); + assert!(image.pixel(70, 95)[..3].iter().all(|v| *v < 90)); + assert_eq!(image.pixel(5, 130), [180, 180, 180, 255]); + // Optional synthetic preview for manual visual QA, never a checked-in fixture. + if let Ok(path) = std::env::var("DESIGNCRAFT_CMYK_PREVIEW") { + std::fs::write(path, image.to_png()).unwrap(); + } + } + + #[test] + fn cmyk_effects_blend_with_the_ink_backdrop_even_with_render_threads() { + let mut doc = document(); + let item = rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(20.0, 20.0, 120.0, 100.0), "C=100 M=0 Y=0 K=0", DcBlend::Normal); + let satin = &mut doc.item_mut(item).unwrap().effects.satin; + satin.on = true; + satin.color = "C=0 M=100 Y=0 K=0".into(); + satin.opacity = 1.0; + satin.size = 0.0; + satin.angle = 0.0; + satin.distance = 40.0; + let mut renderer = Renderer::new(); + renderer.threads = 2; + let image = draw(&doc, &mut renderer); + let expected = srgb_in(&srgb_table(), [1.0, 1.0, 0.0, 0.0]).map(|v| (v * 255.0).round() as u8); + let actual = image.pixel(60, 60); + assert!(actual[..3].iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 3), "Multiply satin: {actual:?}, expected {expected:?}"); + renderer.threads = 0; + assert_eq!(draw(&doc, &mut renderer).pixels, image.pixels); + // The effect's blend must also see the object's backdrop in the existing RGB path, + // including the multithreaded offscreen-filter implementation. + doc.settings.blend_space = designcraft_doc::BlendSpace::Rgb; + let rgb = draw(&doc, &mut renderer); + let mixed = rgb.pixel(60, 60); + assert!(mixed[0] < 100 && mixed[1] < 100 && mixed[2] > 100, "cyan times magenta is blue, not magenta: {mixed:?}"); + renderer.threads = 2; + assert_eq!(draw(&doc, &mut renderer).pixels, rgb.pixels); + } + + #[test] + fn cmyk_pass_count_is_constant_and_unchanged_views_are_cached() { + let mut doc = document(); + let mut ids = Vec::new(); + for _ in 0..10 { + ids.push(rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(20.0, 20.0, 100.0, 100.0), "C=100 M=0 Y=0 K=0", DcBlend::Multiply)); + } + let mut renderer = Renderer::new(); + renderer.threads = 0; + let before = draw(&doc, &mut renderer); + assert_eq!(renderer.cmyk_passes, 2, "ten Multiply objects still need only two ink passes"); + assert_eq!(draw(&doc, &mut renderer).pixels, before.pixels); + assert_eq!(renderer.cmyk_passes, 2, "an unchanged frame performs no ink passes"); + doc.item_mut(ids[9]).unwrap().fill = Fill::swatch(designcraft_color::swatch::BLACK); + let after = draw(&doc, &mut renderer); + assert_ne!(after.pixel(70, 50), before.pixel(70, 50)); + assert_eq!(renderer.cmyk_passes, 4); + renderer.clear_caches(); + assert!(renderer.cmyk_view.is_none()); + assert_eq!(draw(&doc, &mut renderer).pixels, after.pixels); + assert_eq!(renderer.cmyk_passes, 6); + let (_, story) = + doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 160.0, 30.0), doc.default_layer(), "Hello", Default::default()).unwrap(); + let text_before = draw(&doc, &mut renderer); + doc.story_mut(story).unwrap().insert(5, " world"); + let text_after = draw(&doc, &mut renderer); + assert_ne!(text_before.pixels, text_after.pixels, "story edits invalidate the viewport"); + assert_eq!(renderer.cmyk_passes, 10); + } + + #[test] + fn cmyk_channels_keep_the_same_alpha_through_transparency() { + let mut doc = document(); + let a = rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(20.0, 20.0, 100.0, 100.0), "C=100 M=0 Y=0 K=0", DcBlend::Normal); + let b = rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(40.0, 20.0, 120.0, 100.0), "C=0 M=100 Y=0 K=0", DcBlend::Multiply); + doc.item_mut(a).unwrap().opacity = 0.5; + doc.item_mut(b).unwrap().opacity = 0.5; + doc.item_mut(b).unwrap().effects.gradient_feather.on = true; + let children = vec![doc.remove_item(a).unwrap(), doc.remove_item(b).unwrap()]; + let mut group = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Group, Default::default()); + group.content = Content::Group { items: children }; + group.opacity = 0.5; + group.isolate = true; + doc.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let placements = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let options = RenderOptions { paper: false, blend_space_view: true, ..Default::default() }; + let out = renderer.render(&doc, &Cache::new(), &placements, 160, 120, Affine::IDENTITY, &options); + 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)) { + 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"); + } + } + + #[test] + fn cmyk_nonseparable_modes_use_the_existing_rgb_approximation() { + let mut doc = document(); + let id = dodge(&mut doc, SpreadRef::Doc(0)); + doc.item_mut(id).unwrap().blend = DcBlend::Hue; + let mut renderer = Renderer::new(); + renderer.threads = 0; + draw(&doc, &mut renderer); + assert_eq!(renderer.cmyk_passes, 0, "RGB Hue must not be evaluated on CMY channels"); + doc.item_mut(id).unwrap().blend = DcBlend::ColorDodge; + draw(&doc, &mut renderer); + assert_eq!(renderer.cmyk_passes, 2); } } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6b29516f4..c1b6cedbc 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -210,11 +210,9 @@ 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_filters(ctx, f, reach, sigma, |_, c, fr| { + self.with_blended_filters(ctx, f, reach, sigma, Mix::Screen, |_, c, fr| { c.set_transform(fr.view * xf); c.push_clip_layer(bp); - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Screen, Compose::SrcOver)), None, None, None); c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); c.set_transform(fr.view * xf); @@ -230,7 +228,6 @@ impl Renderer { c.pop_layer(); 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); @@ -245,11 +242,9 @@ 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_filters(ctx, f, reach, sigma, |_, c, fr| { + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, |_, c, fr| { c.set_transform(fr.view * xf); c.push_clip_layer(bp); - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None); c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); if invert { @@ -270,7 +265,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. @@ -304,13 +298,9 @@ impl Renderer { paint: peniko::Color, mix: Mix, ) { - self.with_filters(ctx, f, reach, sigma, |_, c, fr| { + self.with_blended_filters(ctx, f, reach, sigma, mix, |_, c, fr| { c.set_transform(fr.view * xf); c.push_clip_layer(bp); - if mix != Mix::Normal { - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); - } 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. @@ -329,13 +319,31 @@ impl Renderer { c.stroke_path(&shape); } c.pop_layer(); - if mix != Mix::Normal { - c.pop_layer(); - } c.pop_layer(); }); } + /// 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. + fn with_blended_filters( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + reach: Rect, + sigma: f64, + mix: Mix, + 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); + if mix != Mix::Normal { + ctx.pop_layer(); + } + } + /// 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. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5285a9bdc..84b3a3667 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -113,7 +113,7 @@ impl Rendered { } /// What to draw. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct RenderOptions { /// Canvas background (premultiplied RGBA8); `None` = transparent. pub background: Option<[u8; 4]>, @@ -219,6 +219,9 @@ pub struct Renderer { glyphs: text::GlyphCache, /// Composed stories looked up during the current render. stories: HashMap<(StoryId, Option), Arc>, + cmyk_view: Option, + #[cfg(test)] + cmyk_passes: usize, pub threads: u16, pub stats: FrameStats, } @@ -264,13 +267,17 @@ impl Renderer { resources: Resources::new(), glyphs: text::GlyphCache::default(), stories: HashMap::new(), + cmyk_view: None, + #[cfg(test)] + cmyk_passes: 0, threads: default_threads(), stats: FrameStats::default(), } } - /// Drop cached glyph paths (they are rebuilt on demand). + /// Drop cached glyph paths and the CMYK viewport (rebuilt on demand). pub fn clear_caches(&mut self) { + self.cmyk_view = None; self.glyphs.clear(); self.stories.clear(); } @@ -290,6 +297,38 @@ impl Renderer { height: u32, view: Affine, opts: &RenderOptions, + ) -> Rendered { + let start = now(); + RICH_BLACK.with(|r| r.set(opts.rich_black)); + PLATE.with(|p| p.set(opts.plate)); + if let Some(hit) = self.cmyk_view.as_ref().filter(|entry| entry.matches(doc, spreads, width, height, view, opts, self.threads)) { + let image = hit.image(); + self.stats = hit.stats(); + self.stats.micros = now().saturating_sub(start); + return image; + } + // Release the old snapshot before rendering an edited document or resized viewport. + self.cmyk_view = None; + let image = self.render_uncached(doc, cache, spreads, width, height, view, opts); + if opts.blend_space_view + && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk + && image.pixels.len() <= blendspace::CachedView::MAX_BYTES + { + self.cmyk_view = Some(blendspace::CachedView::new(doc, spreads, view, opts, &image, self.stats, self.threads)); + } + self.stats.micros = now().saturating_sub(start); + image + } + #[allow(clippy::too_many_arguments)] + fn render_uncached( + &mut self, + doc: &Document, + cache: &Cache, + spreads: &[Placed], + width: u32, + height: u32, + view: Affine, + opts: &RenderOptions, ) -> Rendered { let start = now(); let w = width.clamp(1, MAX_SIDE) as u16; @@ -332,19 +371,11 @@ impl Renderer { if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) { ctx.render(pm, &mut self.resources); } - // A CMYK blend space blends the spreads' blend modes in CMYK (blendspace.rs). - if opts.blend_space_view && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk && opts.plate.is_none() { - let stats = self.stats; - for pl in spreads { - self.cmyk_blend_spread(doc, cache, pl, w as u32, h as u32, view, opts, &mut pixels); - } - self.stats = stats; - } if opts.blend_space_view && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk && opts.plate.is_none() { let mut img = Rendered { width: w as u32, height: h as u32, pixels }; for pl in spreads { let Some(sp) = doc.spread(pl.spread) else { continue }; - if sp.items.iter().any(|it| it.involves_transparency() && !it.hidden) { + if blendspace::uses_rgb_proof(doc, pl.spread, opts) { let r = (view * pl.xf).transform_rect_bbox(sp.bounds()); blend_space_view(&mut img, r); } @@ -404,6 +435,10 @@ impl Renderer { } } } + if blendspace::uses_cmyk(doc, sr, f.opts) { + self.draw_cmyk_spread(ctx, f, sr); + return; + } // 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 { @@ -600,9 +635,9 @@ impl Renderer { { let r = it.inner_bounds(); ctx.set_transform(f.view * xf); - ctx.set_paint(peniko::Color::from_rgba8(80, 140, 255, 40)); + ctx.set_paint(screen_color([80, 140, 255], 40)); ctx.fill_rect(&r); - ctx.set_paint(peniko::Color::from_rgba8(60, 110, 220, 200)); + ctx.set_paint(screen_color([60, 110, 220], 200)); ctx.set_stroke(kurbo::Stroke::new(0.75)); ctx.stroke_path(&r.to_path(0.1)); if ff.kind == designcraft_doc::FieldKind::CheckBox && !ff.value.is_empty() && ff.value != "Off" { @@ -611,7 +646,7 @@ impl Renderer { c.move_to((r.x0 + w * 0.22, r.y0 + h * 0.48)); c.line_to((r.x0 + w * 0.42, r.y0 + h * 0.72)); c.line_to((r.x0 + w * 0.78, r.y0 + h * 0.24)); - ctx.set_paint(peniko::Color::BLACK); + ctx.set_paint(screen_color([0, 0, 0], 255)); ctx.set_stroke(kurbo::Stroke::new((w.min(h) * 0.12).max(0.8)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); ctx.stroke_path(&c); } @@ -737,7 +772,7 @@ impl Renderer { if designcraft_doc::media_kind(&asset.mime).is_some() { // Video / sound: a dark frame with a play mark (the poster, when set, is drawn by the item). ctx.set_transform(f.view * xf * g.xf); - ctx.set_paint(peniko::Color::from_rgba8(48, 48, 52, 255)); + ctx.set_paint(screen_color([48, 48, 52], 255)); ctx.fill_rect(&Rect::new(0.0, 0.0, g.size.0, g.size.1)); let (cx, cy, r) = (g.size.0 / 2.0, g.size.1 / 2.0, g.size.0.min(g.size.1) * 0.18); let mut tri = BezPath::new(); @@ -745,13 +780,13 @@ impl Renderer { tri.line_to((cx + r, cy)); tri.line_to((cx - r * 0.6, cy + r)); tri.close_path(); - ctx.set_paint(peniko::Color::from_rgba8(235, 235, 235, 255)); + ctx.set_paint(screen_color([235, 235, 235], 255)); ctx.fill_path(&tri); return; } if f.opts.quality == DisplayQuality::Fast { ctx.set_transform(f.view * xf * g.xf); - ctx.set_paint(peniko::Color::from_rgba8(178, 178, 178, 255)); + ctx.set_paint(screen_color([178, 178, 178], 255)); ctx.fill_rect(&Rect::new(0.0, 0.0, g.size.0, g.size.1)); return; } @@ -828,11 +863,25 @@ pub(crate) fn cmyk_in(table: &[[f32; 4]], rgb: [f32; 3]) -> [f32; 4] { /// An image's ink coverage on one process plate, as grey (cached per image and plate). fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync::Arc { - type Cache = std::collections::HashMap<(usize, u8), std::sync::Arc>; + // Keep source allocations alive (their addresses are keys), invalidate on colour settings, + // and cap derived raster memory rather than merely counting differently sized images. + type Entry = (std::sync::Arc, std::sync::Arc); + type Cache = (designcraft_color::cms::ColorSettings, std::collections::HashMap<(usize, u8), Entry>, usize); + const BUDGET: usize = 64 << 20; static CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); let key = (std::sync::Arc::as_ptr(pm) as usize, plate); - if let Some(v) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.get(&key).cloned()) { - return v; + let settings = designcraft_color::cms::active_settings(); + { + let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((previous, map, _)) = guard.as_ref() { + if *previous == settings { + if let Some((_, derived)) = map.get(&key) { + return derived.clone(); + } + } else { + *guard = None; + } + } } let table = cmyk_table(); let data: Vec = pm @@ -854,12 +903,19 @@ fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync: }) .collect(); let out = std::sync::Arc::new(vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height())); - let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); - let map = c.get_or_insert_with(Default::default); - if map.len() > 32 { - map.clear(); + let bytes = (pm.width() as usize).saturating_mul(pm.height() as usize).saturating_mul(8); + if bytes <= BUDGET { + let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let (previous, map, used) = guard.get_or_insert_with(|| (settings.clone(), Default::default(), 0)); + if *previous != settings || used.saturating_add(bytes) > BUDGET { + map.clear(); + *used = 0; + *previous = settings; + } + if map.insert(key, (pm.clone(), out.clone())).is_none() { + *used += bytes; + } } - map.insert(key, out.clone()); out } @@ -876,6 +932,16 @@ thread_local! { static PLATE: std::cell::Cell> = const { std::cell::Cell::new(None) }; } +/// Screen indicators and placeholders are display sRGB, independent of the document's RGB +/// working profile. Encode their inks only while drawing a process plate or CMYK blend pass. +pub(crate) fn screen_color(rgb: [u8; 3], alpha: u8) -> peniko::Color { + if PLATE.with(|plate| plate.get()).is_none() { + return peniko::Color::from_rgba8(rgb[0], rgb[1], rgb[2], alpha); + } + let [c, m, y, k] = cmyk_in(&cmyk_table(), rgb.map(|v| v as f32 / 255.0)); + color_of(&designcraft_color::Color::Cmyk { c, m, y, k }, alpha as f32 / 255.0) +} + pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { if let Some(p) = PLATE.with(|p| p.get()) { let cmyk = match *c { diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index cc46ad729..ee9c6c92d 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -17,7 +17,7 @@ use vello_cpu::RenderContext; use vello_cpu::kurbo; use vello_cpu::peniko; -use crate::{Frame, Renderer, color_of, rect_overlaps}; +use crate::{Frame, Renderer, color_of, rect_overlaps, screen_color}; /// Cached outlines of one composed line. pub(crate) struct LineGlyphs { @@ -221,7 +221,7 @@ impl Renderer { // Text in fonts that aren't installed: pink behind the glyphs (screen only). if f.opts.highlight_missing_fonts && cs.styles.iter().any(|s| s.missing_font) { ctx.set_transform(m); - ctx.set_paint(peniko::Color::from_rgb8(255, 166, 214)); + ctx.set_paint(screen_color([255, 166, 214], 255)); for l in &ft.lines { for g in l.glyphs.iter().filter(|g| g.adv > 0.0 && cs.styles.get(g.style as usize).is_some_and(|s| s.missing_font)) { ctx.fill_rect(&kurbo::Rect::new(g.x, l.baseline - l.ascent, g.x + g.adv, l.baseline + l.descent)); @@ -230,7 +230,7 @@ impl Renderer { } if f.opts.highlight_keeps && ft.lines.iter().any(|l| l.keep_violation) { ctx.set_transform(m); - ctx.set_paint(peniko::Color::from_rgb8(255, 236, 120)); + ctx.set_paint(screen_color([255, 236, 120], 255)); for l in ft.lines.iter().filter(|l| l.keep_violation) { ctx.fill_rect(&kurbo::Rect::new(l.x0, l.baseline - l.ascent, l.x1, l.baseline + l.descent)); } @@ -239,13 +239,13 @@ impl Renderer { ctx.set_transform(m); for l in ft.lines.iter().filter(|l| l.hj > 0) { let shade = [0u8, 0xF6, 0xEA, 0xD8][l.hj.min(3) as usize]; - ctx.set_paint(peniko::Color::from_rgb8(255, shade, [0u8, 0xB0, 0x70, 0x30][l.hj.min(3) as usize])); + ctx.set_paint(screen_color([255, shade, [0u8, 0xB0, 0x70, 0x30][l.hj.min(3) as usize]], 255)); ctx.fill_rect(&kurbo::Rect::new(l.x0, l.baseline - l.ascent, l.end_x.max(l.x0 + 1.0), l.baseline + l.descent)); } } if f.opts.highlight_custom_tracking && cs.styles.iter().any(|s| s.custom_tracking) { ctx.set_transform(m); - ctx.set_paint(peniko::Color::from_rgb8(176, 236, 176)); + ctx.set_paint(screen_color([176, 236, 176], 255)); for l in &ft.lines { for g in l.glyphs.iter().filter(|g| g.adv > 0.0 && cs.styles.get(g.style as usize).is_some_and(|s| s.custom_tracking)) { ctx.fill_rect(&kurbo::Rect::new(g.x, l.baseline - l.ascent, g.x + g.adv, l.baseline + l.descent)); @@ -269,7 +269,7 @@ impl Renderer { j += 1; } let c = f.doc.xml.tags.iter().find(|t| t.name == tag).map_or([100, 100, 220], |t| t.color); - ctx.set_paint(peniko::Color::from_rgb8(c[0], c[1], c[2])); + ctx.set_paint(screen_color([c[0], c[1], c[2]], 255)); let (x0, x1) = (gs[i].x, gs[j - 1].x + gs[j - 1].adv); let (top, bot) = (l.baseline - l.ascent, l.baseline + l.descent); let (w, t) = (2.5 * px, px.max(0.3)); @@ -289,7 +289,7 @@ impl Renderer { ctx.set_transform(m); for l in &ft.lines { for g in l.glyphs.iter().filter(|g| g.adv > 0.0 && cs.styles.get(g.style as usize).is_some_and(|s| s.inserted)) { - ctx.set_paint(peniko::Color::from_rgba8(120, 190, 255, 110)); + ctx.set_paint(screen_color([120, 190, 255], 110)); ctx.fill_rect(&kurbo::Rect::new(g.x, l.baseline - l.ascent, g.x + g.adv, l.baseline + l.descent)); } } @@ -299,7 +299,7 @@ impl Renderer { { // A small flag at each note anchor, in the notes' amber. ctx.set_transform(m); - ctx.set_paint(peniko::Color::from_rgb8(232, 160, 32)); + ctx.set_paint(screen_color([232, 160, 32], 255)); for l in &ft.lines { for g in l.glyphs.iter().filter(|g| st.text.get(g.byte..).is_some_and(|t| t.starts_with(designcraft_doc::NOTE_MARK))) { let h = l.ascent.max(4.0); @@ -320,7 +320,7 @@ impl Renderer { for g in l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0) { let Some(name) = cs.styles.get(g.style as usize).and_then(|s| s.condition.as_deref()) else { continue }; let Some(c) = f.doc.conditions.iter().find(|c| c.name == name) else { continue }; - ctx.set_paint(peniko::Color::from_rgb8(c.color[0], c.color[1], c.color[2])); + ctx.set_paint(screen_color([c.color[0], c.color[1], c.color[2]], 255)); let y = l.baseline + l.descent * 0.6; ctx.fill_rect(&kurbo::Rect::new(g.x, y, g.x + g.adv, y + 2.0 * px)); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4fb784456..fcd038076 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -315,7 +315,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. - Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported. - File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. -- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK). Their separable blend modes blend in CMYK too (the object’s own inks against the backdrop separated through the colour settings), so Color Dodge with 100% K whitens coloured areas as in InDesign. +- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, CMYK spreads with separable blend modes use two ink-channel passes through the working colour settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens coloured areas while paper stays outside the blend backdrop. Effect blend layers sit outside their clips and offscreen filters, preserving their backdrop in RGB too. The pass count is independent of blended-object count, and unchanged viewports reuse one cached raster (64 MiB cap). Spreads containing Hue, Saturation, Color or Luminosity still use the existing RGB blend followed by a CMYK proof approximation; full nonseparable CMYK blending remains open. - High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). - Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans). - Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images. From 6f4fa3e56da61b4fafacdf6e01887c87711217d6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 11:14:47 +0200 Subject: [PATCH 039/200] 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 040/200] 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 041/200] 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 7192c2e13684e985f8fb2787d3bffe8b4f859976 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 23:48:11 +0200 Subject: [PATCH 042/200] 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 5a5e97b0a64892dac87b52d6a3d1f4bdb410d575 Mon Sep 17 00:00:00 2001 From: natocool2 Date: Tue, 13 Oct 2026 09:07:47 +0100 Subject: [PATCH 043/200] Bump quick-xml to 0.41 (RUSTSEC-2026-0194, RUSTSEC-2026-0195) The .docx/.xlsx text import iterates attributes with quick-xml's default duplicate check, so one crafted start tag with N attributes cost O(N^2) (RUSTSEC-2026-0194, fixed in quick-xml 0.41). Bumping also clears RUSTSEC-2026-0195 (NsReader namespace allocation), which this workspace does not reach (no NsReader). unescape_value() is deprecated in 0.41 (clippy -D warnings); normalized_value(Implicit1_0) is what the deprecated method now does. Regression test: an element with 100,000 attributes parses in < 5 s. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 17 ++++------------- Cargo.toml | 2 +- crates/idml/src/xml.rs | 5 ++++- crates/textimport/src/docx.rs | 22 ++++++++++++++++++++-- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef525c84e..07ae0c036 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -983,7 +983,7 @@ dependencies = [ "hayro-syntax", "kurbo", "log", - "quick-xml 0.38.4", + "quick-xml", "regex", "serde", "serde_json", @@ -1046,7 +1046,7 @@ dependencies = [ "designcraft-compose", "designcraft-doc", "designcraft-geom", - "quick-xml 0.38.4", + "quick-xml", "thiserror 2.0.21", "zip", ] @@ -1120,7 +1120,7 @@ name = "designcraft-textimport" version = "0.7.1" dependencies = [ "designcraft-doc", - "quick-xml 0.38.4", + "quick-xml", "zip", ] @@ -3505,15 +3505,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -4989,7 +4980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.41.0", + "quick-xml", "quote", ] diff --git a/Cargo.toml b/Cargo.toml index b08a82fc2..6653e3209 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ flo_curves = "0.8" qrcodegen = "1.8" krilla = { version = "0.8", features = ["pdf"] } hayro = "0.7" -quick-xml = "0.38" +quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # ui diff --git a/crates/idml/src/xml.rs b/crates/idml/src/xml.rs index cb09249ad..97aab239b 100644 --- a/crates/idml/src/xml.rs +++ b/crates/idml/src/xml.rs @@ -283,7 +283,10 @@ fn start_el(e: &quick_xml::events::BytesStart) -> Result { for a in e.attributes().with_checks(false) { let a = a.map_err(|e| e.to_string())?; let k = String::from_utf8_lossy(a.key.as_ref()).to_string(); - let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string()); + let v = a + .normalized_value(quick_xml::XmlVersion::Implicit1_0) + .map(|v| v.to_string()) + .unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string()); el.attrs.push((k, v)); } Ok(el) diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 67a2b71a1..1b4f28cb5 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -67,7 +67,9 @@ pub(crate) fn parse(xml: &str) -> Result { attrs: e .attributes() .flatten() - .map(|a| (local(a.key.as_ref()), a.unescape_value().map(|v| v.to_string()).unwrap_or_default())) + .map(|a| { + (local(a.key.as_ref()), a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map(|v| v.to_string()).unwrap_or_default()) + }) .collect(), kids: vec![], }; @@ -79,7 +81,9 @@ pub(crate) fn parse(xml: &str) -> Result { attrs: e .attributes() .flatten() - .map(|a| (local(a.key.as_ref()), a.unescape_value().map(|v| v.to_string()).unwrap_or_default())) + .map(|a| { + (local(a.key.as_ref()), a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map(|v| v.to_string()).unwrap_or_default()) + }) .collect(), kids: vec![], }; @@ -510,4 +514,18 @@ mod tests { i.story.check().unwrap(); assert_eq!(i.story.text, "Kept text. MOVED HERE. Plain INSERTED\nEnd."); } + + #[test] + fn an_element_with_many_attributes_parses_in_linear_time() { + // RUSTSEC-2026-0194: before quick-xml 0.41 the duplicate-attribute check compared each name with every + // earlier one on the element, so a crafted .docx/.xlsx with one huge tag hung the import (O(N²)). + let attrs: String = (0..100_000).map(|i| format!(" w:a{i}=\"{i}\"")).collect(); + let xml = format!(""); + let started = std::time::Instant::now(); + let doc = parse(&xml).unwrap(); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "parse took {:?}", started.elapsed()); + let p = doc.child("body").and_then(|b| b.child("p")).unwrap(); + assert_eq!(p.attr("a0"), Some("0")); + assert_eq!(p.attr("a99999"), Some("99999")); + } } From 39ec5013517d085caab5d1998eb154aa14bbb907 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 10:12:43 +0200 Subject: [PATCH 044/200] fix(edit): copy a selected graphic on its own; delete leaves the frame With a placed image selected via Direct Selection (InDesign's content selection), Copy now copies the graphic itself: a frame of the whole image's bounds (uncropped) at its spread position, without the frame's fill, stroke or effects. Paste Into then puts the full picture in place inside the target shape, as InDesign does (LYH Studio episode 14, 5:44). Cut and Delete with the graphic selected remove only the graphic; the frame stays, empty and selected. Nested items copy in spread coordinates (parent transform applied). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 80 ++++++++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..e20a395cf 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** · Copy with a placed graphic selected (Direct Selection) copies just the graphic, uncropped, where it sits on the page and without the frame's stroke, so Paste Into puts the whole image in place; Cut and Delete then remove only the graphic and leave its frame empty. Nested selections copy in their spread coordinates. 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/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e7190acb4..d6ef9d118 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -189,11 +189,20 @@ pub fn specs() -> Vec { return super::text::delete_selection(s); } let ids = super::targets(s, p)?; + // With a placed graphic itself selected (Direct Selection), only the graphic goes: its + // frame stays, empty and selected (as in InDesign). Other items are deleted. + let content = s.doc()?.selection.content && p.get("ids").is_none(); s.edit(|d, sel| { + let mut kept = Vec::new(); for id in &ids { - let _ = d.remove_item(*id); + if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) { + it.content = designcraft_doc::Content::Unassigned; + kept.push(*id); + } else { + let _ = d.remove_item(*id); + } } - *sel = Selection::default(); + *sel = Selection::items(kept); Ok(json!({"deleted": ids.len()})) }) }), @@ -266,12 +275,15 @@ pub(crate) fn clip_doc(s: &Session) -> Result { let st = s.doc()?; let mut d = (*st.doc).clone(); let keep: Vec = st.selection.items.clone(); - // Gather the selected items (keep their spread coordinates) into spread 0. + // Gather the selected items (in their spread coordinates, nested ones too) into spread 0. let mut items = Vec::new(); for id in &keep { - if let Some(it) = st.doc.item(*id) { - items.push(Arc::new(it.clone())); - } + let (Some(it), Some(loc)) = (st.doc.item(*id), st.doc.find(*id)) else { continue }; + let parent = st.doc.parent_xf(&loc); + items.push(Arc::new(match &it.content { + designcraft_doc::Content::Graphic(g) if st.selection.content => graphic_alone(it, g, parent), + _ => designcraft_doc::Item { xf: parent * it.xf, ..it.clone() }, + })); } for sp in d.spreads.iter_mut() { Arc::make_mut(sp).items.clear(); @@ -307,6 +319,22 @@ pub(crate) fn clip_doc(s: &Session) -> Result { Ok(d) } +/// A placed graphic on its own, as InDesign copies one selected with Direct Selection: a frame of +/// the whole graphic's bounds (uncropped), where the graphic sits on the spread (`parent` is the +/// frame's parent transform), without the frame's fill, stroke or effects. +fn graphic_alone(frame: &designcraft_doc::Item, g: &designcraft_doc::Graphic, parent: designcraft_geom::Affine) -> designcraft_doc::Item { + let r = designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1); + let mut it = designcraft_doc::Item::new(frame.id, frame.layer, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + it.xf = parent * frame.xf * g.xf; + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + xf: designcraft_geom::Affine::IDENTITY, + auto_fit: designcraft_doc::Fitting::None, + crop: [0.0; 4], + ..g.clone() + }); + it +} + #[cfg(test)] mod text_clipboard_tests { use super::*; @@ -454,4 +482,44 @@ mod paste_into_tests { assert!(img.pixel(120, 120)[0] > 200, "{:?}", img.pixel(120, 120)); s.doc().unwrap().doc.check().unwrap(); } + + #[test] + fn a_selected_graphic_copies_and_clears_on_its_own() { + let dir = std::env::temp_dir().join(format!("dc-graphic-alone-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("photo.png"); + std::fs::write(&png, designcraft_render::Rendered { width: 200, height: 100, pixels: vec![40u8; 200 * 100 * 4] }.to_png()).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // A square frame showing the middle of a wide photo (cropped left and right). + let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap(); + s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [frame]})).unwrap(); + s.execute("object.stroke", &json!({"ids": [frame], "swatch": "[Black]", "weight": 4})).unwrap(); + // Direct Selection on the photo, Copy: the whole photo, where it is, without the frame's stroke. + s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let clip = s.clipboard.clone().unwrap(); + let it = &clip.spreads[0].items[0]; + assert!(matches!(&it.content, designcraft_doc::Content::Graphic(g) if g.crop == [0.0; 4])); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(50.0, 100.0, 250.0, 200.0)); + assert!(it.stroke.weight == 0.0 || it.stroke.swatch == "[None]", "{:?}", it.stroke); + // Paste Into a shape over it: the photo lands in place, inside the shape. + let shape = s.execute("frame.create", &json!({"rect": [60, 90, 240, 210], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [shape]})).unwrap(); + s.execute("edit.pasteInto", &json!({})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let kid = d.item(ItemId(shape)).unwrap().children()[0].clone(); + let at = d.parent_xf(&d.find(kid.id).unwrap()) * kid.xf; + assert!((at * designcraft_geom::Point::new(0.0, 0.0) - designcraft_geom::Point::new(50.0, 100.0)).hypot() < 1e-6); + assert!((at * designcraft_geom::Point::new(200.0, 100.0) - designcraft_geom::Point::new(250.0, 200.0)).hypot() < 1e-6); + // Delete with the photo selected: the frame stays, empty. + s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap(); + assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 1); + let d = s.doc().unwrap().doc.clone(); + assert!(matches!(d.item(ItemId(frame)).unwrap().content, designcraft_doc::Content::Unassigned)); + assert_eq!(s.doc().unwrap().selection, Selection::items(vec![ItemId(frame)])); + d.check().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } } From 65bb4e3f661fc6541432fbd99d7d40ea33ba4e49 Mon Sep 17 00:00:00 2001 From: Noah Scholz <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:17:34 +0000 Subject: [PATCH 045/200] fix(tables): keep each table anchor in its own paragraph Backspace at the start of the second of two consecutive tables merged their anchor paragraphs: Story::delete kept one table id, the other table was dropped and the paragraph held two anchors. Pasting a table next to an anchor replaced the existing table the same way, and pasting several paragraphs right before an anchor left its table unreferenced. Saving succeeded; reopening failed with "paragraph must hold exactly one anchor". fix_tables now splits a table paragraph that holds further anchors, so every anchor gets its own paragraph again. delete and insert_story tell it which table each surviving anchor belongs to (fix_tables_with), and insert_story fixes tables up once after the whole paste instead of after every run. Regression tests in doc (Backspace between tables, deleting the middle of three, paste before/after an anchor, paragraphs pasted before an anchor) and engine (text.delete and edit.paste through commands, then a file round-trip); all failed before the fix. Claude-Session: https://claude.ai/code/session_01La6SDgsmdZfgfDDmx3wiGU --- ROADMAP.md | 3 +- crates/doc/src/slice.rs | 55 +++++++++++++++++----- crates/doc/src/story.rs | 22 +++++---- crates/doc/src/table.rs | 78 +++++++++++++++++++++++++++++--- crates/engine/src/tests_table.rs | 37 +++++++++++++++ 5 files changed, 168 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 91a951616..2788ac9cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **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-13 · **Change:** minor (table anchors keep their own paragraphs) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Backspace between two consecutive tables, pasting a table next to a table anchor, and pasting several paragraphs right before one keep every table on its own anchor paragraph. Previously a table was dropped or left without its anchor, and the saved file failed to reopen. 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/doc/src/slice.rs b/crates/doc/src/slice.rs index f85ec4adc..cc320bd09 100644 --- a/crates/doc/src/slice.rs +++ b/crates/doc/src/slice.rs @@ -57,18 +57,25 @@ impl Story { if src.text.is_empty() { return pos; } - // Text first (creates empty marker entries), then formats and marker payloads. + // The table anchored in the paragraph at `pos` keeps its anchor wherever the paste moves it. + let p0 = self.para_at(pos); + let own = self.paras.get(p0).and_then(|p| p.table).and_then(|id| { + let r = self.para_ranges().get(p0)?.clone(); + Some((r.start + self.text.get(r)?.find(TABLE_ANCHOR)?, id)) + }); + // Text first (creates empty marker entries), then formats and marker payloads; tables are + // fixed up once at the end. let mut at = pos; for (r, f) in src.runs() { if r.is_empty() { continue; } - self.insert_with(at, &src.text[r.clone()], f.clone()); + self.insert_with_raw(at, &src.text[r.clone()], f.clone()); at += r.len(); } let end = at; + let mut claims: Vec<(usize, u64)> = own.map(|(a, id)| (if a >= pos { a + (end - pos) } else { a }, id)).into_iter().collect(); // Paragraph formats of the pasted paragraphs after the first. - let p0 = self.para_at(pos); for (k, pf) in src.paras.iter().enumerate().skip(1) { if let Some(p) = self.paras.get_mut(p0 + k) { *p = crate::story::ParaFormat { table: None, ..pf.clone() }; @@ -106,25 +113,25 @@ impl Story { *slot = Arc::new(crate::xref::TextAnchor { id, name: a.name.clone() }); } } - // Tables: re-attach by paragraph (fresh ids so a pasted copy never shares an id). + // Tables: re-attach at their anchors (fresh ids so a pasted copy never shares an id). if !src.tables.is_empty() { let mut next = self.tables.keys().max().copied().unwrap_or(0) + 1; - for (k, pf) in src.paras.iter().enumerate() { + let ranges = src.para_ranges(); + for (pf, r) in src.paras.iter().zip(ranges) { let Some(tid) = pf.table else { continue }; let Some(t) = src.tables.get(&tid) else { continue }; - let pi = p0 + k; + let Some(k) = src.text.get(r.clone()).and_then(|t| t.find(TABLE_ANCHOR)) else { continue }; let mut t = (**t).clone(); t.id = next; next += 1; + claims.push((pos + r.start + k, t.id)); self.tables.insert(t.id, Arc::new(t)); - if let Some(p) = self.paras.get_mut(pi) { - p.table = Some(next - 1); - } } - self.fix_tables(); } + let splits = self.fix_tables_with(&claims); self.rev += 1; - end + // Past any return the fix-up put inside the pasted text. + end + splits.iter().filter(|&&s| s < end).count() } } @@ -133,6 +140,32 @@ mod tests { use super::*; use crate::story::ParaFormat; + #[test] + fn pasting_a_table_next_to_another_gives_each_its_own_paragraph() { + let mut s = Story::with_text(StoryId(1), "x", ParaFormat::default()); + let a = s.insert_table(1, crate::Table::new(5, 1, 1, 0, 0, 10.0)); + let n = TABLE_ANCHOR.len_utf8(); + let copy = s.extract(a..a + n); + // After the anchor: the copy follows the original. + let mut after = s.clone(); + let end = after.insert_story(a + n, ©); + after.check().unwrap(); + assert_eq!(after.text, format!("x\n{TABLE_ANCHOR}\n{TABLE_ANCHOR}\n")); + assert_eq!((after.table_anchor(5), after.table_anchor(6)), (Some(a), Some(a + n + 1))); + assert_eq!(end, a + 2 * n + 1); + // Before the anchor: the copy comes first and the original keeps its table. + let mut before = s.clone(); + let end = before.insert_story(a, ©); + before.check().unwrap(); + assert_eq!((before.table_anchor(6), before.table_anchor(5)), (Some(a), Some(a + n + 1))); + assert_eq!(end, a + n); + // Paragraphs pasted before the anchor keep the table too. + let mut text = s.clone(); + text.insert_story(a, &Story::with_text(StoryId(2), "Hello\nWorld", ParaFormat::default())); + text.check().unwrap(); + assert_eq!(text.table_anchor(5), Some(a + "Hello\nWorld".len())); + } + #[test] fn copy_paste_keeps_formatting_and_notes() { let mut a = Story::with_text(StoryId(1), "Alpha beta\nGamma delta", ParaFormat::default()); diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 6dbfa13c6..98d63eb82 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -255,7 +255,8 @@ impl Story { self.insert_with_raw(pos, text, fmt); } - fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) { + /// [`Story::insert_with`] without the table fix-up. + pub(crate) fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) { if text.is_empty() { return; } @@ -332,20 +333,25 @@ impl Story { self.marks_deleted(a, b); let pi = self.para_at(a); let removed = self.text[a..b].bytes().filter(|c| *c == b'\n').count(); - // Merged paragraphs keep a table whose anchor survives the deletion. + // Merged paragraphs keep the tables whose anchors survive the deletion (the fix-up gives + // each its own paragraph again). + let mut claims = Vec::new(); if removed > 0 && !self.tables.is_empty() { let ranges = self.para_ranges(); - let first = self.paras[pi].table.filter(|_| self.text[ranges[pi].start..a].contains(TABLE_ANCHOR)); - let last = self.paras[pi + removed] - .table - .filter(|_| self.text[b.min(ranges[pi + removed].end)..ranges[pi + removed].end].contains(TABLE_ANCHOR)); - self.paras[pi].table = first.or(last); + let anchor = |pi: usize, r: std::ops::Range| { + self.paras[pi].table.and_then(|id| self.text[r.clone()].find(TABLE_ANCHOR).map(|k| (r.start + k, id))) + }; + let first = anchor(pi, ranges[pi].start..a); + let end = ranges[pi + removed].end; + let last = anchor(pi + removed, b.min(end)..end).map(|(at, id)| (at.saturating_sub(b - a), id)); + self.paras[pi].table = first.or(last).map(|(_, id)| id); + claims.extend(first.into_iter().chain(last)); } self.paras.drain(pi + 1..pi + 1 + removed); self.text.replace_range(a..b, ""); self.remove_run_bytes(a, b - a); self.normalize(); - self.fix_tables(); + self.fix_tables_with(&claims); self.rev += 1; } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 7fe385e98..e51a9cbe3 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -1012,21 +1012,61 @@ impl Story { /// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it /// still contains an anchor character, every table is referenced once, and tables nobody - /// references are dropped. + /// references are dropped. An anchor that shares a table's paragraph with another anchor gets + /// a paragraph of its own again, so a table paragraph always holds exactly one anchor. pub(crate) fn fix_tables(&mut self) { + self.fix_tables_with(&[]); + } + + /// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are + /// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph + /// names one table, so merging two anchor paragraphs would otherwise lose one). Every other + /// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before + /// the fix-up, where it inserted paragraph returns. + pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec { if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) { - return; + return Vec::new(); } - let ranges = self.para_ranges(); + let mut owner = std::collections::BTreeMap::new(); let mut used = std::collections::HashSet::new(); - for (p, r) in self.paras.iter_mut().zip(ranges) { - if let Some(id) = p.table - && (!self.text[r].contains(TABLE_ANCHOR) || !self.tables.contains_key(&id) || !used.insert(id)) + for &(at, id) in claims { + if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR)) + && self.tables.contains_key(&id) + && !owner.contains_key(&at) + && used.insert(id) { - p.table = None; + owner.insert(at, id); } } + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter().zip(&ranges) { + let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue }; + let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at)); + if let Some(at) = free { + used.insert(id); + owner.insert(at, id); + } + } + // A paragraph holding a table's anchor and other anchors is split before each later anchor. + let mut splits = Vec::new(); + for r in &ranges { + let anchors: Vec = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect(); + if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) { + splits.extend(anchors.into_iter().skip(1)); + } + } + for &at in splits.iter().rev() { + self.insert_raw(at, "\n"); + } + // Each owned anchor, moved past the returns inserted before it, names its paragraph's table. + let owner: std::collections::BTreeMap = + owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect(); + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter_mut().zip(ranges) { + p.table = owner.range(r).next().map(|(_, &id)| id); + } self.tables.retain(|id, _| used.contains(id)); + splits } /// Check table invariants (part of [`Story::check`]). @@ -1097,6 +1137,30 @@ mod tests { assert!(s.tables.is_empty()); } + #[test] + fn deleting_the_return_between_two_tables_keeps_both() { + // Backspace at the start of the second table's paragraph. + let mut s = story("x"); + let a1 = s.insert_table(1, Table::new(1, 1, 1, 0, 0, 10.0)); + let a2 = s.insert_table(s.len(), Table::new(2, 1, 1, 0, 0, 10.0)); + let before = s.text.clone(); + s.delete(a2 - 1..a2); + s.check().unwrap(); + assert_eq!(s.text, before); + assert_eq!((s.table_anchor(1), s.table_anchor(2)), (Some(a1), Some(a2))); + // Deleting the table between two others keeps the outer ones on their own anchors. + let mut s = story("x"); + for id in 1..=3 { + s.insert_table(s.len(), Table::new(id, 1, 1, 0, 0, 10.0)); + } + let (a1, a3) = (s.table_anchor(1).unwrap(), s.table_anchor(3).unwrap()); + s.delete(a1 + TABLE_ANCHOR.len_utf8()..a3); + s.check().unwrap(); + assert_eq!(s.text, format!("x\n{TABLE_ANCHOR}\n{TABLE_ANCHOR}\n")); + assert_eq!(s.tables.keys().copied().collect::>(), [1, 3]); + assert_eq!(s.table_anchor(3), Some(a1 + TABLE_ANCHOR.len_utf8() + 1)); + } + #[test] fn edits_around_the_anchor_keep_the_table() { let mut s = story("ab\ncd"); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 45861347d..4ca02f889 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -177,6 +177,43 @@ fn file_roundtrip_keeps_tables() { assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "A1"); } +#[test] +fn backspace_and_paste_between_tables_keep_both_and_the_file_reopens() { + let n = designcraft_doc::TABLE_ANCHOR.len_utf8(); + let anchor_of = |s: &Session, sid: u64, tid: u64| s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().table_anchor(tid).unwrap(); + let reopens_with = |s: &Session, sid: u64, ids: &[u64]| { + let back = crate::cmd::file_from(&crate::cmd::file_bytes(&s.doc().unwrap().doc)).unwrap(); + let st = back.story(doc::StoryId(sid)).unwrap(); + assert!(ids.iter().all(|id| st.table_anchor(*id).is_some()), "{:?}", st.tables.keys()); + assert_eq!(st.tables.len(), ids.len()); + }; + // Backspace at the start of the second of two consecutive tables. + let (mut s, sid, _) = session_with_frame(); + let t1 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let len = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().len(); + s.execute("text.select", &json!({"story": sid, "anchor": len})).unwrap(); + let t2 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let a2 = anchor_of(&s, sid, t2); + s.execute("text.select", &json!({"story": sid, "anchor": a2})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); + check(&s); + reopens_with(&s, sid, &[t1, t2]); + // Pasting a copy of a table right after its anchor. + let (mut s, sid, _) = session_with_frame(); + let t1 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let a1 = anchor_of(&s, sid, t1); + s.execute("text.select", &json!({"story": sid, "anchor": a1, "focus": a1 + n})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": a1 + n})).unwrap(); + let pasted = s.execute("edit.paste", &json!({})).unwrap(); + check(&s); + assert_eq!(pasted["pos"], a1 + 2 * n + 1); + let st = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().clone(); + let copy = st.tables.keys().copied().find(|id| *id != t1).unwrap(); + assert_eq!((st.table_anchor(t1), st.table_anchor(copy)), (Some(a1), Some(a1 + n + 1))); + reopens_with(&s, sid, &[t1, copy]); +} + fn table_for(s: &Session, sid: u64, tid: u64) -> &doc::Table { s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap() } From f55b94f3ff32c88bcd99b1eed7ef20cd86b458f8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 10:21:39 +0200 Subject: [PATCH 046/200] fix(type): Create Outlines makes one compound path per line As in InDesign, outlining a text frame gives a group with one compound path per line (split further by text colour within a line), so Ungroup leaves each line on its own: LYH Studio episode 14 ungroups the outlined title at 4:56 to tighten the space between its lines before making one compound path again. A single line in one colour stays a single compound path. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/paths.rs | 38 ++++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..8838a2cee 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** · Create Outlines makes one compound path per line (and per text colour within a line), grouped, as InDesign does: Ungroup then leaves each line on its own to be spaced and made into one compound path again. A single line in one colour stays a single compound path. 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/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..e9511f260 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -35,7 +35,7 @@ pub fn specs() -> Vec { "Create Outlines", ["Type"], Some("Cmd+Shift+O"), - "{ids?} — text frames become compound paths of their glyphs (one per text colour, grouped when several) → {ids}", + "{ids?} — text frames become compound paths of their glyphs (one per line and text colour, grouped when several, so Ungroup separates the lines) → {ids}", has_selection, create_outlines ), @@ -245,9 +245,10 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { }; let cs = designcraft_compose::compose_story(d, tf.story, &Default::default()); let Some(ft) = cs.frame(*id) else { continue }; - // One path per text look (fill, stroke). - let mut runs: Vec<((String, f32, String, f32, f64), BezPath)> = Vec::new(); - for l in &ft.lines { + // One compound path per line and text look (fill, stroke), grouped: as in InDesign, + // Ungroup then leaves each line on its own. + let mut runs: Vec<((usize, String, f32, String, f32, f64), BezPath)> = Vec::new(); + for (li, l) in ft.lines.iter().enumerate() { for g in l.glyphs.iter().filter(|g| g.visible) { let st = &cs.styles[g.style as usize]; let outline = db.outline(&g.face, g.gid); @@ -256,7 +257,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { } let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); - let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight); + let key = (li, st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight); let k = match runs.iter().position(|r| r.0 == key) { Some(k) => k, None => { @@ -274,7 +275,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?; let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id)); let text_xf = d.text_xf(&it); - let make = |d: &mut Document, ((fill, ft, stroke, stt, sw), bp): ((String, f32, String, f32, f64), BezPath)| { + let make = |d: &mut Document, ((_, fill, ft, stroke, stt, sw), bp): ((usize, String, f32, String, f32, f64), BezPath)| { let mut o = Item::new(ItemId(d.alloc()), it.layer, Shape::Path, PathData::from_bezpath(&bp)); o.xf = text_xf; o.fill = Fill { swatch: fill, tint: ft, ..Fill::none() }; @@ -454,6 +455,31 @@ mod tests { assert!(d.stories.is_empty() || !d.stories.values().any(|st| st.frames.contains(&ItemId(id)))); } + #[test] + fn outlines_of_several_lines_ungroup_line_by_line() { + 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": "GOOD\nMORNING"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + let d = s.doc().unwrap().doc.clone(); + let group = d.item(ItemId(g)).unwrap(); + assert!(group.is_group(), "a group of the lines"); + let lines: Vec<_> = group.children().iter().map(|c| c.bounds()).collect(); + assert_eq!(lines.len(), 2, "one compound path per line"); + assert!(lines[0].y1 <= lines[1].y0 + 1.0, "GOOD above MORNING: {lines:?}"); + // Ungroup: each line on its own, to space them and make one compound path again. + s.execute("object.ungroup", &json!({})).unwrap(); + let sel = s.doc().unwrap().selection.items.clone(); + assert_eq!(sel.len(), 2); + let d = s.doc().unwrap().doc.clone(); + assert!(sel.iter().all(|i| d.item(*i).is_some_and(|it| it.shape == Shape::Path && it.path.subpaths.len() >= 4))); + s.execute("object.makeCompoundPath", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.items.len(), 1); + s.doc().unwrap().doc.check().unwrap(); + } + #[test] fn scissors_split_open_and_closed_paths() { let mut s = Session::new(); From 3b36c286949bcb571ff5b5a818ab4f111589673c Mon Sep 17 00:00:00 2001 From: haranishi <223786623+haranishi@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:25:53 +0900 Subject: [PATCH 047/200] =?UTF-8?q?test:=20macOS=E3=81=AEMCP=E5=86=8D?= =?UTF-8?q?=E6=8E=A5=E7=B6=9A=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E3=82=BD?= =?UTF-8?q?=E3=82=B1=E3=83=83=E3=83=88=E8=A8=AD=E5=AE=9A=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/mcp/src/backend.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index debdbb124..ca4f588d9 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -172,6 +172,9 @@ mod tests { Err(e) => panic!("accept failed: {e}"), } }; + // Accepted sockets can inherit the listener's nonblocking mode. The server + // waits for both requests with timeouts, rather than reading before they arrive. + 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()); From 96e9f371acdde103172e74122454e85d16db3fa0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 12:02:57 +0200 Subject: [PATCH 048/200] fix(render): effects apply to frames with pasted-in content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A frame holding items pasted into it (Edit › Paste Into) was drawn by its own branch in draw_item that skipped every effect: Basic Feather, Inner Glow, shadows and the rest were stored but never drawn. LYH Studio episode 14 (11:51) gives the outlined "GET MORE." title, with a photo pasted into it, an Inner Glow and a Basic Feather. Such a frame now goes the way of any other object: draw_body draws its fill, its items clipped to the frame and its stroke, so draw_item_fx wraps all of it. Its silhouette counts as solid for shadow spread. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/render/src/fx.rs | 2 +- crates/render/src/lib.rs | 45 +++++++++---------------------- crates/render/tests/effects.rs | 48 ++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 34 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..90520070e 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** · Object effects (Basic Feather, Inner Glow, shadows, glows, bevel, satin, directional and gradient feathers) apply to frames with pasted-in content, on screen and in image exports: the frame, its clipped content and its stroke are one object to the effect. PDF export still has no soft effects for any object. 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/render/src/fx.rs b/crates/render/src/fx.rs index 6b29516f4..d90f9afea 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -74,7 +74,7 @@ impl Renderer { let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0); // The silhouette is the shape when the object paints its area (fill / image); text frames // without a fill cast the shadow of their text only, which can't be grown. - let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_)); + let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_)) || it.has_nested_items(); let mut below = vec![]; if e.outer_glow.on { let g = &e.outer_glow; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..1dab91899 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -436,39 +436,9 @@ impl Renderer { } let xf = parent * it.xf; let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(it.stroke.extent() + 2.0, it.stroke.extent() + 2.0); - if it.has_nested_items() { - // A frame with items pasted into it: fill, the items clipped to the frame, stroke. - if !rect_overlaps(vb, f.visible) { - return; - } - let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) }; - let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); - } - if !it.fill.is_none() { - ctx.set_transform(f.view * xf); - if set_fill_paint(ctx, f.doc, &it.fill, bp.bounding_box()) { - ctx.fill_path(&bp); - } - ctx.reset_paint_transform(); - } - ctx.set_transform(f.view * xf); - ctx.push_clip_layer(&bp); - for c in it.children() { - self.draw_item(ctx, f, c, xf, page); - } - ctx.pop_layer(); - if !it.stroke.is_none() { - self.draw_stroke(ctx, f, it, &bp, xf); - } - if layered { - ctx.pop_layer(); - } - return; - } - if !it.children().is_empty() { + // A frame with items pasted into it draws like any other object (draw_body: fill, the items + // clipped to the frame, stroke), so its effects apply to all of it. + if !it.children().is_empty() && !it.has_nested_items() { // Groups: children carry their own transforms relative to the group. // Isolate Blending / Knockout: the group is its own layer (blend modes stop at it). let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal || it.isolate || it.knockout; @@ -573,6 +543,15 @@ impl Renderer { } ctx.pop_layer(); } + // Items pasted into the frame, clipped by it. + Content::Group { items } if it.has_nested_items() => { + ctx.set_transform(f.view * xf); + ctx.push_clip_layer(bp); + for c in items { + self.draw_item(ctx, f, c, xf, page); + } + ctx.pop_layer(); + } Content::Text(tf) => { let cs = self.story(f, tf.story, page); if let Some(ft) = cs.frame(it.id) { diff --git a/crates/render/tests/effects.rs b/crates/render/tests/effects.rs index a664c27a8..bd0160ee1 100644 --- a/crates/render/tests/effects.rs +++ b/crates/render/tests/effects.rs @@ -222,3 +222,51 @@ fn multithreaded_effects_match_single_threaded() { assert!(worst <= 4, "case {i}: max channel difference {worst}"); } } + +/// A frame (30..130) holding a pasted-in black square that overhangs it (Edit › Paste Into). +fn pasted_into(d: &mut Document) -> ItemId { + let id = rect_item(d, Rect::new(30.0, 30.0, 130.0, 130.0), "[None]"); + let kid = Item { + fill: Fill::swatch("[Black]"), + ..Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 160.0, 160.0))) + }; + d.item_mut(id).unwrap().content = designcraft_doc::Content::Group { items: vec![std::sync::Arc::new(kid)] }; + assert!(d.item(id).unwrap().has_nested_items()); + id +} + +#[test] +fn effects_apply_to_a_frame_with_pasted_in_items() { + let mut d = doc(); + let id = pasted_into(&mut d); + let plain = render(&d); + let black = lum(plain.pixel(80, 80)); + assert!(black < 200 && lum(plain.pixel(31, 80)) <= black + 6, "the content fills the frame: {:?} {:?}", plain.pixel(31, 80), plain.pixel(80, 80)); + assert_eq!(lum(plain.pixel(27, 80)), WHITE, "clipped by the frame"); + // Basic Feather fades the frame's edge, content and all. + d.item_mut(id).unwrap().effects.feather = 16.0; + let img = render(&d); + assert!(lum(img.pixel(30, 80)) > 600, "nearly transparent at the path: {:?}", img.pixel(30, 80)); + assert!(lum(img.pixel(80, 80)) <= black + 6, "solid inside"); + assert_eq!(lum(img.pixel(27, 80)), WHITE, "still clipped"); + // Inner Glow: white screened in from the edges. + d.item_mut(id).unwrap().effects.feather = 0.0; + let g = &mut d.item_mut(id).unwrap().effects.inner_glow; + g.on = true; + g.size = 12.0; + g.opacity = 1.0; + let img = render(&d); + assert!(lum(img.pixel(32, 80)) > black + 150, "lit at the edge: {:?}", img.pixel(32, 80)); + assert!(lum(img.pixel(80, 80)) <= black + 6, "dark in the middle"); + // Drop shadow: cast by the content's silhouette, outside the frame. + d.item_mut(id).unwrap().effects.inner_glow.on = false; + let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow; + ds.on = true; + ds.angle = 180.0; + ds.global_light = false; + ds.distance = 12.0; + ds.size = 0.0; + ds.opacity = 1.0; + let img = render(&d); + assert!(lum(img.pixel(136, 80)) < 300, "the shadow to the right: {:?}", img.pixel(136, 80)); +} From 27302d8ea33b44460bca8642584a2bb32da111a8 Mon Sep 17 00:00:00 2001 From: haranishi <223786623+haranishi@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:18:40 +0900 Subject: [PATCH 049/200] =?UTF-8?q?fix:=20Links=E3=83=91=E3=83=8D=E3=83=AB?= =?UTF-8?q?=E3=81=AB=E3=83=AA=E3=83=B3=E3=82=AF=E5=85=83=E3=83=91=E3=82=B9?= =?UTF-8?q?=E3=82=92=E8=A1=A8=E7=A4=BA=20#569?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 3 +- crates/ui-egui/src/panels/properties.rs | 150 +++++++++++++++++++++++- 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..aab4166ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **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-13 · **Change:** minor (Links source paths) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Links rows show their source paths; selecting a link shows its full path wrapped inside the panel, so same-named files from different folders can be distinguished. 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/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..aa612313c 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1911,6 +1911,8 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } let sel_key = egui::Id::new("links_panel_sel"); let mut chosen: Option = ui.data(|d| d.get_temp(sel_key)); + // Leave room for Status and Page/PPI instead of widening the grid for long source paths. + let name_width = (ui.available_width() - 160.0).max(50.0); egui::Grid::new("links_grid").num_columns(3).striped(true).spacing([10.0, 4.0]).show(ui, |ui| { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Name")).color(t.text_dim).size(11.0)); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Status")).color(t.text_dim).size(11.0)); @@ -1919,7 +1921,25 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { for r in &rows { let aid = r["asset"].as_u64().unwrap_or(0); let name = r["name"].as_str().unwrap_or(""); - if ui.selectable_label(chosen == Some(aid), name).clicked() { + let response = ui + .vertical(|ui| { + ui.set_width(name_width); + let name = ui.add(egui::Button::selectable(chosen == Some(aid), name).truncate()); + match r["path"].as_str().filter(|path| !path.is_empty()) { + Some(path) => name.union( + ui.add( + egui::Label::new(egui::RichText::new(path).color(t.text_dim).size(11.0)) + .truncate() + .sense(Sense::click()) + .show_tooltip_when_elided(false), + ) + .on_hover_text(path), + ), + None => name, + } + }) + .inner; + if response.clicked() { chosen = Some(aid); } let (txt, col) = match r["status"].as_str().unwrap_or("") { @@ -1952,7 +1972,12 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(sel_key, c)); } ui.add_space(6.0); - ui.horizontal(|ui| { + if let Some(path) = rows.iter().find(|r| r["asset"].as_u64() == chosen).and_then(|r| r["path"].as_str()).filter(|path| !path.is_empty()) { + // A selected link stays distinguishable even if two rows truncate to the same prefix. + ui.add(egui::Label::new(egui::RichText::new(path).color(t.text_dim).size(11.0)).wrap()); + ui.add_space(6.0); + } + ui.horizontal_wrapped(|ui| { let Some(aid) = chosen else { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Choose a link.")).color(t.text_dim).size(11.0)); return; @@ -2239,6 +2264,127 @@ mod tests { text_attrs(app).unwrap()["chars"]["language"].clone() } + fn linked_graphics(paths: &[Option<&str>]) -> (DesignApp, Vec, Vec) { + use designcraft_doc::{Asset, AssetId, Content, Graphic, ItemId}; + use std::sync::Arc; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"title": "Linked graphics"})).unwrap(); + let mut items = Vec::new(); + let mut assets = Vec::new(); + for (index, path) in paths.iter().enumerate() { + let x = 72 + index * 100; + let id = ItemId(app.run("frame.create", json!({"rect": [x, 72, x + 72, 144]})).unwrap()["id"].as_u64().unwrap()); + let doc = Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + let aid = AssetId(doc.alloc()); + doc.assets.insert( + aid, + Arc::new(Asset { + id: aid, + name: "photo.svg".into(), + mime: "image/svg+xml".into(), + link: path.map(str::to_owned), + data: Arc::new( + br##""## + .to_vec(), + ), + pixels: Some((32, 32)), + page: 0, + }), + ); + doc.item_mut(id).unwrap().content = Content::Graphic(Graphic { + asset: aid, + size: (72.0, 72.0), + xf: designcraft_geom::Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + items.push(id); + assets.push(aid); + } + (app, items, assets) + } + + 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( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), ""); + crate::theme::apply(ui.ctx(), &Tokens::for_brightness(Default::default())); + ready = true; + return; + } + links_panel(app, ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(3); + 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"; + let second = "/project/assets/studio-b/photo.svg"; + let (app, items, assets) = linked_graphics(&[Some(first), Some(second), None]); + 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]]); + h.get_by_label("Embed").click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().doc.assets[&assets[1]].link.is_none()); + assert_eq!(h.state().session.active().unwrap().doc.assets[&assets[0]].link.as_deref(), Some(first)); + assert!(h.query_by_label(second).is_none(), "an embedded graphic no longer shows a source path"); + 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]]); + } + + #[test] + fn links_panel_keeps_long_source_paths_inside_a_narrow_panel() { + let path = "/project/assets/studio-a/very-long-folder-name/another-long-folder-name/photo.svg"; + let (app, _, _) = linked_graphics(&[Some(path)]); + let mut h = links_harness(app, 240.0); + let source = h.get_by_label(path).rect(); + assert!(source.width() < 150.0, "the source is truncated to its column: {source:?}"); + assert!(source.right() <= 240.0, "the source stays inside the narrow panel: {source:?}"); + assert!(h.get_by_label("Page · PPI").rect().right() <= 240.0); + 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] fn properties_character_section_sets_the_language() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 573e2da84a02618056590dcf39e5d21af5faa1ee Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 13 Oct 2026 03:59:05 -0700 Subject: [PATCH 050/200] Swatches: dropping an .ase file on the window loads its swatches open_dropped sent every dropped file that is not a document to file.place, so a swatch exchange (.ase) file dropped on the desktop window failed with a "Can't Place the File" alert: "invalid parameters for `file.place`: unsupported or corrupt image". open_dropped now sends .ase files to swatch.load with the dropped path, as Load Swatches in the Swatches panel menu does with the picked path. The web app already sent dropped .ase files to swatch.load. The swatches are added to the active document as Load Swatches adds them, and if the file can't be loaded, the status line shows the error. --- ROADMAP.md | 1 + crates/ui-egui/src/lib.rs | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..bf06425bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,6 +156,7 @@ Newest first. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. +- **2026-10-13** · Dropping an `.ase` file on the window loads its swatches, as Load Swatches does (the drop failed with "Can't Place the File"). No readiness percentage change. - **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. - **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..568587759 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -969,8 +969,12 @@ impl DesignApp { } } - /// A file dropped on the window: documents open, anything else is placed. + /// A file dropped on the window: documents open, the swatches in a swatch exchange (.ase) file + /// are loaded, and anything else is placed. pub fn open_dropped(&mut self, path: &str) -> Result { + if path.to_ascii_lowercase().ends_with(".ase") { + return self.run("swatch.load", json!({"path": path})); + } let cmd = if opens_as_document(path) { "file.open" } else { "file.place" }; self.open_file(cmd, json!({"path": path})) } @@ -1888,6 +1892,28 @@ mod tests { assert_eq!(alert(&app), ("Can't Place the File".to_string(), e)); } + #[test] + fn dropping_an_ase_file_loads_its_swatches() { + let mut source = Session::new(); + source.execute("file.new", &json!({})).unwrap(); + source.execute("swatch.create", &json!({"name": "Dropped Teal", "color": "#108080"})).unwrap(); + let data = source.execute("swatch.save", &json!({"names": ["Dropped Teal"]})).unwrap(); + let bytes = designcraft_engine::cmd::base64_decode(data["base64"].as_str().unwrap()); + for name in ["palette.ase", "PALETTE.ASE"] { + let path = std::env::temp_dir().join(format!("designcraft-ui-{}-{name}", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.open_dropped(&path.to_string_lossy()); + let _ = std::fs::remove_file(&path); + assert!(app.ui.dialog.is_none(), "{name}: {:?}", app.ui.dialog.as_ref().map(|d| &d.fields)); + assert_eq!(r, Ok(json!({"added": 1, "replaced": 0})), "{name}"); + let doc = &app.session.doc().unwrap().doc; + assert!(doc.swatches.iter().any(|s| s.name == "Dropped Teal"), "{name}"); + assert!(doc.assets.is_empty(), "{name} isn't placed"); + } + } + #[test] fn file_open_failures_show_an_alert() { let path = unreadable_file("open.designcraft"); From e3ee342a4addab0c92e0715dee694a605987b120 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 12:12:16 +0000 Subject: [PATCH 051/200] feat(ui): drag a dialog by its title to move it --- ROADMAP.md | 1 + crates/ui-egui/src/about.rs | 12 +++++- crates/ui-egui/src/dialogs.rs | 71 +++++++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/modal_drag.rs | 57 +++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/modal_drag.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..f626b2f28 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** · Dialogs and the About window move when you drag their title row, so the page behind them can be seen. A dragged dialog stays inside the window, and each dialog opens centred. 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/about.rs b/crates/ui-egui/src/about.rs index 4eae2e7c6..d0323a999 100644 --- a/crates/ui-egui/src/about.rs +++ b/crates/ui-egui/src/about.rs @@ -106,8 +106,18 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } let t = Tokens::get(ctx); let mut open: Option<&'static str> = None; - let resp = egui::Modal::new(egui::Id::new("about")).show(ctx, |ui| { + let area = egui::Id::new("about"); + let instance = area.with("placement"); + let resp = crate::modal_drag::modal(ctx, area, instance).show(ctx, |ui| { ui.set_width(if app.ui.about_tab == 0 { 460.0 } else { 640.0 }); + // The tab row is the title row. The tabs register after the drag area, so they stay clickable. + let margin = egui::Frame::popup(ui.style()).inner_margin; + let top = ui.cursor().top(); + let title_row = Rect::from_min_max( + pos2(ui.max_rect().left() - margin.leftf(), top - margin.topf()), + pos2(ui.max_rect().right() + margin.rightf(), top + ui.spacing().interact_size.y), + ); + crate::modal_drag::title_drag(ui, instance, title_row); ui.horizontal(|ui| { for (i, l) in ABOUT_TABS.iter().enumerate() { let i = i as u8; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af1..c24c2ba0f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1155,7 +1155,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { None => crate::i18n::tr(&app.ui.language, "Dialog"), }, }; - egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| { + let host = egui::Id::new("dialog"); + let instance = host.with(&d.id); + crate::modal_drag::modal(ctx, host, instance).show(ctx, |ui| { let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum(); let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { @@ -1167,6 +1169,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let width = preferred_width.min(available.x); ui.set_min_width(380.0_f32.min(width)); ui.set_max_width(width); + let title_top = ui.cursor().top(); if crate::i18n::is_rtl(&app.ui.language) { ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -1176,6 +1179,12 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } else { crate::rtl::label(ui, egui::RichText::new(title).font(semibold(16.0))); } + let margin = egui::Frame::popup(ui.style()).inner_margin; + let title_row = egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left() - margin.leftf(), title_top - margin.topf()), + egui::pos2(ui.max_rect().right() + margin.rightf(), ui.min_rect().bottom()), + ); + crate::modal_drag::title_drag(ui, instance, title_row); ui.add_space(10.0); // Keep the title and action buttons outside the scrollable body. Oversized // forms remain reachable at large UI scales, without shrinking their text. @@ -4776,4 +4785,64 @@ 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 dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { + use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; + use egui_kittest::{Harness, kittest::Queryable}; + fn drag(h: &mut Harness<'static, DesignApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for step in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0))); + h.run_steps(1); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + } + let rect = |h: &Harness<'static, DesignApp>| h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog"))).unwrap(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.dialog = Some(Dialog::new("insertTable", Value::Null)); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::default())); + ready = true; + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + let centred = rect(&h); + assert!((centred.center() - egui::pos2(600.0, 400.0)).length() < 1.0, "{centred:?}"); + assert!(centred.width() < 1000.0 && centred.height() < 700.0, "the area is the dialog, not the backdrop: {centred:?}"); + + let title = h.get_by_label("Create Table").rect().center(); + drag(&mut h, title, title + vec2(100.0, 60.0)); + let moved = rect(&h); + assert!((moved.min - centred.min - vec2(100.0, 60.0)).length() < 2.0, "{centred:?} -> {moved:?}"); + + let body = egui::pos2(moved.right() - 40.0, moved.center().y); + drag(&mut h, body, body + vec2(-150.0, 80.0)); + assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place"); + + // The dialog stays inside the window. + let title = h.get_by_label("Create Table").rect().center(); + drag(&mut h, title, title - vec2(0.0, 1000.0)); + assert!(rect(&h).top() >= 0.0, "{:?}", rect(&h)); + + // Opened again, the dialog starts centred. + h.state_mut().ui.dialog = None; + h.run_steps(2); + h.state_mut().ui.dialog = Some(Dialog::new("insertTable", Value::Null)); + h.run_steps(4); + assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h)); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..d53d2c490 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod dock; pub mod i18n; pub mod icons; pub mod menus; +mod modal_drag; mod panel_docking; pub mod panels; mod prefs; diff --git a/crates/ui-egui/src/modal_drag.rs b/crates/ui-egui/src/modal_drag.rs new file mode 100644 index 000000000..2828a58a8 --- /dev/null +++ b/crates/ui-egui/src/modal_drag.rs @@ -0,0 +1,57 @@ +//! Modal windows (dialogs, About) that the user moves by dragging their title row. +//! +//! The modal stays anchored at the centre of the window and is shifted by an offset kept in +//! egui's temporary memory. A modal that was not shown in the previous pass starts centred. +//! egui keeps the whole modal inside the window, so its title row stays reachable. + +use egui::{Align2, Context, CursorIcon, Id, Modal, Rect, Sense, Ui, Vec2, vec2}; + +#[derive(Clone, Copy, Default)] +struct Placement { + offset: Vec2, + /// The pass the modal was last shown in: a gap means it was closed and opened again. + pass: u64, +} + +/// A modal centred in the window and shifted by the user's drag. +/// +/// `area` is the modal's area id. `instance` names what it shows (for example the dialog kind), +/// so a different dialog in the same host starts centred. +pub(crate) fn modal(ctx: &Context, area: Id, instance: Id) -> Modal { + let pass = ctx.cumulative_pass_nr(); + let size = ctx.memory(|m| m.area_rect(area)).map(|r| r.size()); + let content = ctx.content_rect(); + let offset = ctx.data_mut(|d| { + let p = d.get_temp_mut_or_default::(instance); + if p.pass.saturating_add(1) < pass { + *p = Placement::default(); + } + p.pass = pass; + if let Some(size) = size { + p.offset = clamp(p.offset, size, content); + } + p.offset + }); + Modal::new(area).area(Modal::default_area(area).anchor(Align2::CENTER_CENTER, offset)) +} + +/// Makes `rect`, the modal's title row, drag the modal opened with [`modal`] for `instance`. +/// +/// Widgets registered after this call sit on top of the drag area and keep their own input. +pub(crate) fn title_drag(ui: &Ui, instance: Id, rect: Rect) { + let resp = ui.interact(rect, instance.with("title_drag"), Sense::drag()); + if resp.dragged() { + let delta = resp.drag_delta(); + ui.ctx().data_mut(|d| d.get_temp_mut_or_default::(instance).offset += delta); + ui.ctx().set_cursor_icon(CursorIcon::Grabbing); + } else if resp.hovered() { + ui.ctx().set_cursor_icon(CursorIcon::Grab); + } +} + +/// Limits `offset` to the range in which a modal of `size` fits inside `content`, the range egui +/// constrains it to, so dragging back from beyond an edge moves it at once. +fn clamp(offset: Vec2, size: Vec2, content: Rect) -> Vec2 { + let room = ((content.size() - size) * 0.5).max(Vec2::ZERO); + vec2(offset.x.min(room.x).max(-room.x), offset.y.min(room.y).max(-room.y)) +} From c9a09989c8cccb8f23be576892bbeec6ec52428b Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 12:28:23 +0000 Subject: [PATCH 052/200] feat(ui): a dragged dialog may go partly off-screen --- ROADMAP.md | 2 +- crates/ui-egui/src/dialogs.rs | 9 ++++-- crates/ui-egui/src/modal_drag.rs | 51 +++++++++++++++++++++++--------- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f626b2f28..9f5bc414c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-13** · Dialogs and the About window move when you drag their title row, so the page behind them can be seen. A dragged dialog stays inside the window, and each dialog opens centred. No readiness percentage change. +- **2026-10-13** · Dialogs and the About window move when you drag their title row, so the page behind them can be seen. A dragged dialog may go partly off-screen, but its title row stays reachable; each dialog opens centred, and again after the window is resized. 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/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c24c2ba0f..364e5323c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4833,10 +4833,10 @@ mod tests { drag(&mut h, body, body + vec2(-150.0, 80.0)); assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place"); - // The dialog stays inside the window. + // The title row stays inside the top edge. let title = h.get_by_label("Create Table").rect().center(); drag(&mut h, title, title - vec2(0.0, 1000.0)); - assert!(rect(&h).top() >= 0.0, "{:?}", rect(&h)); + assert!(rect(&h).top().abs() < 1.0, "{:?}", rect(&h)); // Opened again, the dialog starts centred. h.state_mut().ui.dialog = None; @@ -4844,5 +4844,10 @@ mod tests { h.state_mut().ui.dialog = Some(Dialog::new("insertTable", Value::Null)); h.run_steps(4); assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h)); + + // Past the left edge, about 100 px of the title row stays inside. + let title = h.get_by_label("Create Table").rect().center(); + drag(&mut h, title, title - vec2(900.0, 0.0)); + assert!((rect(&h).right() - 100.0).abs() < 2.0, "{:?}", rect(&h)); } } diff --git a/crates/ui-egui/src/modal_drag.rs b/crates/ui-egui/src/modal_drag.rs index 2828a58a8..aabe103d2 100644 --- a/crates/ui-egui/src/modal_drag.rs +++ b/crates/ui-egui/src/modal_drag.rs @@ -1,16 +1,24 @@ //! Modal windows (dialogs, About) that the user moves by dragging their title row. //! //! The modal stays anchored at the centre of the window and is shifted by an offset kept in -//! egui's temporary memory. A modal that was not shown in the previous pass starts centred. -//! egui keeps the whole modal inside the window, so its title row stays reachable. +//! egui's temporary memory. A modal that was not shown in the previous pass, or whose window +//! changed size, starts centred. An undragged modal keeps egui's constraint to the window; a +//! dragged one may go partly off-screen, but its title row stays reachable. use egui::{Align2, Context, CursorIcon, Id, Modal, Rect, Sense, Ui, Vec2, vec2}; +/// How much of the title row's width stays inside the window. +const KEEP_VISIBLE: f32 = 100.0; + #[derive(Clone, Copy, Default)] struct Placement { offset: Vec2, /// The pass the modal was last shown in: a gap means it was closed and opened again. pass: u64, + /// The window content size the offset belongs to. + content: Vec2, + /// From the top of the modal to the bottom of its title row. + title_height: f32, } /// A modal centred in the window and shifted by the user's drag. @@ -23,16 +31,20 @@ pub(crate) fn modal(ctx: &Context, area: Id, instance: Id) -> Modal { let content = ctx.content_rect(); let offset = ctx.data_mut(|d| { let p = d.get_temp_mut_or_default::(instance); - if p.pass.saturating_add(1) < pass { - *p = Placement::default(); + if p.pass.saturating_add(1) < pass || p.content != content.size() { + *p = Placement { content: content.size(), ..Placement::default() }; } p.pass = pass; - if let Some(size) = size { - p.offset = clamp(p.offset, size, content); + if let Some(size) = size + && p.offset != Vec2::ZERO + { + p.offset = clamp(p.offset, size, p.title_height, content); } p.offset }); - Modal::new(area).area(Modal::default_area(area).anchor(Align2::CENTER_CENTER, offset)) + // Dragged: `clamp` keeps the title row reachable instead of egui keeping the whole modal inside. + let constrained = offset == Vec2::ZERO; + Modal::new(area).area(Modal::default_area(area).anchor(Align2::CENTER_CENTER, offset).constrain(constrained)) } /// Makes `rect`, the modal's title row, drag the modal opened with [`modal`] for `instance`. @@ -40,18 +52,29 @@ pub(crate) fn modal(ctx: &Context, area: Id, instance: Id) -> Modal { /// Widgets registered after this call sit on top of the drag area and keep their own input. pub(crate) fn title_drag(ui: &Ui, instance: Id, rect: Rect) { let resp = ui.interact(rect, instance.with("title_drag"), Sense::drag()); + let delta = if resp.dragged() { resp.drag_delta() } else { Vec2::ZERO }; + let top = ui.ctx().memory(|m| m.area_rect(ui.layer_id().id)).map_or(rect.top(), |r| r.top()); + ui.ctx().data_mut(|d| { + let p = d.get_temp_mut_or_default::(instance); + p.offset += delta; + p.title_height = (rect.bottom() - top).max(0.0); + }); if resp.dragged() { - let delta = resp.drag_delta(); - ui.ctx().data_mut(|d| d.get_temp_mut_or_default::(instance).offset += delta); ui.ctx().set_cursor_icon(CursorIcon::Grabbing); } else if resp.hovered() { ui.ctx().set_cursor_icon(CursorIcon::Grab); } } -/// Limits `offset` to the range in which a modal of `size` fits inside `content`, the range egui -/// constrains it to, so dragging back from beyond an edge moves it at once. -fn clamp(offset: Vec2, size: Vec2, content: Rect) -> Vec2 { - let room = ((content.size() - size) * 0.5).max(Vec2::ZERO); - vec2(offset.x.min(room.x).max(-room.x), offset.y.min(room.y).max(-room.y)) +/// Limits `offset` so the title row of a modal of `size` stays inside `content` vertically and +/// [`KEEP_VISIBLE`] of its width stays inside horizontally. A modal taller than the window keeps +/// its top edge inside. +fn clamp(offset: Vec2, size: Vec2, title_height: f32, content: Rect) -> Vec2 { + let centred = Align2::CENTER_CENTER.align_size_within_rect(size, content); + let keep = KEEP_VISIBLE.min(size.x); + let x_lo = content.left() + keep - size.x - centred.left(); + let x_hi = content.right() - keep - centred.left(); + let y_lo = content.top() - centred.top(); + let y_hi = content.bottom() - title_height.min(size.y) - centred.top(); + vec2(offset.x.min(x_hi).max(x_lo), offset.y.min(y_hi).max(y_lo)) } From a59919bd2456ea638b9e552220bcad6829d1e63f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 14:26:00 +0200 Subject: [PATCH 053/200] 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 054/200] =?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 d227ee84e9c4bd98edb4614330da92b2e635814c Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 13:00:34 +0000 Subject: [PATCH 055/200] fix(script): only a well-formed `$N` reference is replaced; `$$` is a literal `$` (#749) --- ROADMAP.md | 1 + apps/designcraft-cli/src/main.rs | 2 + crates/engine/src/script.rs | 64 +++++++++++++++++++++++++++----- crates/mcp/src/tools.rs | 3 +- docs/agents.md | 3 +- 5 files changed, 61 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..6badabc04 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** · Command scripts look up only a well-formed `$N.path` / `$last.path` reference; other strings starting with `$`, such as a GREP replacement of `$1` and a nonbreaking space, stay as written, and `$$` writes a literal `$` (#749). 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/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index dd62cd91d..4ae49cc00 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -55,6 +55,8 @@ References to earlier results (in string parameters): comments and blank lines are not steps. `$last` is the previous step. Paths go through result fields and array indices: "$1.story", "$3.ids.0". "${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}". + Any other string starting with `$` stays as written ("$1-$2", "$5 off"). + "$$" a literal `$` anywhere: "$$1" passes `$1` (a GREP group), "$${0.id}" passes `${0.id}`. Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab. Most commands ignore parameters they do not know instead of rejecting them: check names diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index d2304e651..4a66ff87f 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -5,7 +5,8 @@ //! becomes the JSON value at `path` in the result of step `N` (0-based): after //! `frame.create → {"id": 8, "story": 9}`, `{"story": "$0.story"}` passes `9`. Paths are dotted //! keys and array indices (`$2.ids.0`). Inside longer strings `${N.path}` is replaced by the -//! value's text (`"Frame ${0.id}"`). +//! value's text (`"Frame ${0.id}"`). Any other string starting with `$` is plain text (`"$1-$2"`, +//! `"$5 off"`), and `$$` anywhere writes a literal `$` (`"$$1"` passes `$1`, a GREP group). //! //! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`), //! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and @@ -92,27 +93,44 @@ fn short(v: &Value) -> String { if s.len() > 120 { format!("{}…", &s[..s.floor_char_boundary(120)]) } else { s } } +/// Whether `r` (the text after `$`) is a whole reference: digits or `last`, then zero or more +/// `.segment` of ASCII letters, digits, `_` or `-`. +fn is_reference(r: &str) -> bool { + let mut parts = r.split('.'); + let head = parts.next().unwrap_or(""); + let head_ok = head == "last" || (!head.is_empty() && head.bytes().all(|b| b.is_ascii_digit())); + head_ok && parts.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')) +} + /// Replace references in `params` with values from earlier `results`. pub fn resolve(params: &Value, results: &[Value]) -> Result { Ok(match params { Value::String(s) => { - if let Some(r) = s.strip_prefix('$').filter(|r| !r.is_empty() && !r.contains(['{', ' '])) { + if let Some(r) = s.strip_prefix('$').filter(|r| is_reference(r)) { return lookup(r, results); } - if !s.contains("${") { + if !s.contains("${") && !s.contains("$$") { return Ok(params.clone()); } let mut out = String::new(); let mut rest = s.as_str(); - while let Some(i) = rest.find("${") { + while let Some(i) = rest.find('$') { out.push_str(&rest[..i]); - let end = rest[i..].find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))? + i; - let v = lookup(&rest[i + 2..end], results)?; - match v { - Value::String(t) => out.push_str(&t), - other => out.push_str(&other.to_string()), + let after = &rest[i + 1..]; + if let Some(tail) = after.strip_prefix('$') { + out.push('$'); + rest = tail; + } else if let Some(inner) = after.strip_prefix('{') { + let end = inner.find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))?; + match lookup(&inner[..end], results)? { + Value::String(t) => out.push_str(&t), + other => out.push_str(&other.to_string()), + } + rest = &inner[end + 1..]; + } else { + out.push('$'); + rest = after; } - rest = &rest[end + 1..]; } out.push_str(rest); Value::String(out) @@ -196,6 +214,32 @@ mod tests { assert_eq!(r, json!({"story": 9, "id": 5, "label": "Frame 8 of A", "keep": "$", "list": ["A"]})); assert!(resolve(&json!({"x": "$5.id"}), &results).unwrap_err().contains("hasn't run")); assert!(resolve(&json!({"x": "$0.nope"}), &results).unwrap_err().contains("no `nope`")); + assert!(resolve(&json!({"x": "Frame ${0.id"}), &results).unwrap_err().contains("unclosed")); + } + + /// GREP replacements start with `$` too; only a well-formed reference is looked up (#749). + #[test] + fn only_well_formed_references_resolve() { + let results = vec![json!({"id": 8})]; + let p = json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$$1", "e": "$${0.id}", "f": "$0.id", "g": "${0.id}x", "h": "$1$$", "i": "$1."}); + let r = resolve(&p, &results).unwrap(); + assert_eq!(r, json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$1", "e": "${0.id}", "f": 8, "g": "8x", "h": "$1$", "i": "$1."})); + } + + /// Czech typesetting: a nonbreaking space after one-letter prepositions. + #[test] + fn a_script_grep_change_keeps_its_group_reference() { + let mut s = crate::Session::new(); + let steps = vec![ + Step { command: "file.new".into(), params: json!({}) }, + Step { command: "frame.create".into(), params: json!({"rect": [36, 36, 300, 300], "content": "text", "text": "a v Praze"}) }, + Step { command: "find.change".into(), params: json!({"find": r"\b([av]) ", "change": "$1\u{a0}", "grep": true, "caseSensitive": true}) }, + Step { command: "story.get".into(), params: json!({"story": "$1.story"}) }, + ]; + let r = s.run_script(&steps); + assert!(r.failed.is_none(), "{r:?}"); + assert_eq!(r.results[2]["count"], 2); + assert_eq!(r.results[3]["text"], "a\u{a0}v\u{a0}Praze"); } #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c51bac8ed..ccb6bd35e 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -118,7 +118,8 @@ pub fn tool_definitions() -> Vec { and message (earlier commands stay applied; use edit.undo to revert them). Later steps can use earlier results: a \ parameter string \"$N.path\" is replaced by that value of step N's result (0-based; \"$last.path\" for the previous \ step), e.g. after frame.create → {\"id\": 8, \"story\": 9}, {\"story\": \"$0.story\"} passes 9; \"${N.path}\" \ - interpolates into longer strings. Give `commands`, or `script` text (one `command.id {json}` per line).", + interpolates into longer strings. Other strings starting with $ stay as written; \"$$\" is a literal $ \ + (\"$$1\" passes the GREP group $1). Give `commands`, or `script` text (one `command.id {json}` per line).", obj( json!({ "commands": { diff --git a/docs/agents.md b/docs/agents.md index 5115654a2..b737ebb81 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -24,7 +24,8 @@ A script is one command per line (`command.id {json params}`; `#` comments), JSO `{command, params}`, in UTF-8 with or without a byte order mark. A parameter string `"$N.path"` becomes that value of step N's result: N is the 0-based step index (comments and blank lines are not steps, `$last` is the previous step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"` -interpolates inside longer strings. In text parameters `\n` starts a new paragraph and `\u2028` is a forced line +interpolates inside longer strings. Any other string starting with `$` stays as written (`"$1-$2"`, `"$5 off"`), and +`$$` writes a literal `$` anywhere (`"$$1"` passes `$1`, a GREP group). In text parameters `\n` starts a new paragraph and `\u2028` is a forced line break. Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`. `designcraft-cli script --help` prints this reference. From d9b13ab702b3753ae626a72173a513d99860f12a Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:09:26 +0200 Subject: [PATCH 056/200] fix: Cmd/Ctrl + the + key zooms the canvas, not the whole window On layouts with a dedicated + key (German) or Shift+= (US), Zoom In's Cmd+= arrives as Key::Plus. No shortcut matched it, so egui's built-in keyboard zoom scaled the whole UI, and the UI-scale preference reset it on the next frame. Plus now counts as = for shortcuts, and egui's keyboard zoom is off since the UI scale is a preference. --- crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/src/menus.rs | 49 +++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..56866a5d4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -772,6 +772,9 @@ impl DesignApp { ctx.set_zoom_factor(scale); } if !self.styled { + // The UI scale is a preference (applied above); egui's own Cmd+Plus/Minus/0 would rescale the + // whole window instead of the canvas, only to be reset next frame. + ctx.options_mut(|o| o.zoom_with_keyboard = false); theme::install_fonts(ctx, &self.ui.language); self.fonts_lang = self.ui.language.clone(); self.styled = true; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..680f0086f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2172,10 +2172,12 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { let all = effective_shortcuts(app); for (id, sc) in &all { let id = id.as_str(); + // On layouts with a dedicated + key (German) or Shift+= (US), Cmd+= arrives as Plus. + let plus_for_equals = key == egui::Key::Plus; if let Some((m, k)) = parse_shortcut(sc) - && k == key + && (k == key || (plus_for_equals && k == egui::Key::Equals)) && m.command == modifiers.command - && m.shift == modifiers.shift + && (m.shift == modifiers.shift || (plus_for_equals && !m.shift)) && m.alt == modifiers.alt && (m.command || m.alt || !typing) && !(id == "edit.clear" && typing) @@ -3055,6 +3057,49 @@ mod tests { assert_eq!(app.session.tool_id(), "rectangleFrame"); } + #[test] + fn cmd_plus_zooms_the_canvas_not_the_window() { + // A German keyboard has its own + key, so Cmd+= arrives as Plus; it must zoom the canvas like + // Cmd+- does, not hand the key to egui's whole-window zoom. + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let scale = ctx.zoom_factor(); + for (k, m, zooms_in) in [ + (egui::Key::Plus, egui::Modifiers::COMMAND, true), + (egui::Key::Plus, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, true), + (egui::Key::Equals, egui::Modifiers::COMMAND, true), + (egui::Key::Minus, egui::Modifiers::COMMAND, false), + ] { + let z0 = app.view().unwrap().zoom; + frame(&mut app, key(k, m)); + frame(&mut app, vec![]); + let z = app.view().unwrap().zoom; + assert!(if zooms_in { z > z0 } else { z < z0 }, "{k:?} {m:?}: {z0} -> {z}"); + assert_eq!(ctx.zoom_factor(), scale, "{k:?} {m:?} leaves the UI scale alone"); + } + } + #[test] fn the_quick_apply_shortcut_opens_it_without_running_an_entry() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 049ff99590d7abf78553e8666c533f8024a59e04 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:23:33 +0200 Subject: [PATCH 057/200] contributors: add PiMinusMax's display name --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 67a8fdf30..827d12ad6 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -28,5 +28,8 @@ display_name = "insanityxoff" [people.moppeeer] sync_github_name = true +[people.PiMinusMax] +display_name = "PiMinusMax" + [people.thaynej] real_name = "Jeffrey Thayne" From 1527987a8eee8e7d5a537bfa93855961bc3b02d9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 15:26:38 +0200 Subject: [PATCH 058/200] perf(render): CMYK ink passes cover only what transparency can change, with the render threads - The two ink passes run over the area the spread's transparent objects (opacity, blend, effects, knockout, nested or anchored) and their effects' reach can change, not the whole view; the rest is drawn once, through the ordinary renderer, clipped out of that area. Objects the area touches join it whole, so no object is drawn partly through each colour conversion (no seam). A transparent parent-page item still takes the whole view. - The ink passes get the render threads (effects keep their backdrop: with_blended_filters composites the offscreen filters with the blend). - The view is kept in the 64 MiB cache only when the ink passes ran. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/blendspace.rs | 148 +++++++++++++++++++++++++++++--- crates/render/src/lib.rs | 15 +++- 2 files changed, 147 insertions(+), 16 deletions(-) diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 31de419a1..020122572 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -11,7 +11,7 @@ use crate::{Frame, Placed, RenderOptions, Rendered, Renderer}; use designcraft_color::BlendMode as DcBlend; use designcraft_doc::{Document, Item, SpreadRef}; -use designcraft_geom::{Affine, Rect}; +use designcraft_geom::{Affine, Rect, Shape}; use vello_cpu::{RenderContext, peniko}; /// At most one viewport, capped at 64 MiB. The snapshot shares the document's large Arcs; @@ -280,30 +280,108 @@ fn srgb_in(table: &[[f32; 3]], cmyk: [f32; 4]) -> [f32; 3] { } impl Renderer { - /// Two channel passes across the viewport, including bleed and pasteboard. Paper has - /// already been painted in `ctx`; it must never be a blend backdrop. + /// The view pixels the spread's transparency can change: the union of its transparent + /// objects (opacity, blend, effects, knockout, also nested or anchored) with their effects' + /// reach. None: the whole view (a transparent parent-page item, placed through the parent + /// page). Empty: nothing transparent is visible. + fn transparent_area(&self, f: &Frame, sr: SpreadRef) -> Option { + let doc = f.doc; + let sp = doc.spread(sr)?; + let visible = |item: &Item| doc.layer(item.layer).is_some_and(|l| l.visible && (!f.opts.printing_only || l.printable)); + let transparent = |item: &Item| { + let mut t = false; + inspect_item(doc, item, f.opts, 0, &mut t); + t + }; + if let SpreadRef::Doc(si) = sr { + let first = doc.first_page_of_spread(si); + for (pi, page) in sp.pages.iter().enumerate() { + if !page.show_parent_items { + continue; + } + let Some((ppi, _)) = doc.parent_page_for(first.saturating_add(pi)) else { continue }; + if doc + .parents + .get(ppi) + .is_some_and(|parent| parent.items.iter().any(|it| visible(it) && !page.overridden.contains(&it.id) && transparent(it))) + { + return None; + } + } + } + // What an object can paint on the spread: its bounds with its (and its children's) + // strokes and effects. + let reach = |it: &Item| { + let mut o: f64 = 0.0; + it.walk(&mut |c| o = o.max(crate::fx::outset(c) + c.stroke.extent())); + it.bounds().inflate(o + 2.0, o + 2.0) + }; + let items: Vec<(Rect, bool)> = sp.items.iter().filter(|it| visible(it)).map(|it| (reach(it), transparent(it))).collect(); + let mut area: Option = None; + for (r, _) in items.iter().filter(|(_, t)| *t) { + area = Some(area.map_or(*r, |a| a.union(*r))); + } + // Objects the area touches join it whole, so none is drawn partly through the ink passes + // and partly through the ordinary drawing (their colour conversions differ slightly). + let Some(mut area) = area else { return Some(Rect::ZERO) }; + for _ in 0..items.len() { + let grown = items.iter().filter(|(r, _)| r.intersect(area).area() > 0.0).fold(area, |a, (r, _)| a.union(*r)); + if grown == area { + break; + } + area = grown; + } + Some(f.view.transform_rect_bbox(area)) + } + + /// The spread with its transparency blended in CMYK. Paper has already been painted in `ctx`; + /// it must never be a blend backdrop. Only the area transparent objects can change takes the + /// two ink passes (C/M/Y complements, then K); the rest of the view is drawn once, as usual. pub(crate) fn draw_cmyk_spread(&mut self, ctx: &mut RenderContext, f: &Frame, sr: SpreadRef) { let (w, h) = (ctx.width(), ctx.height()); + let view = Rect::new(0.0, 0.0, w as f64, h as f64); + let area = self.transparent_area(f, sr).unwrap_or(view); + let area = Rect::new(area.x0.floor() - 1.0, area.y0.floor() - 1.0, area.x1.ceil() + 1.0, area.y1.ceil() + 1.0).intersect(view); + let rgb = RenderOptions { paper: false, background: None, page_shadow: false, blend_space_view: false, ..f.opts.clone() }; + if area.width() < 1.0 || area.height() < 1.0 { + // Nothing transparent in view: the ordinary drawing is exact. + self.draw_spread(ctx, &Frame { opts: &rgb, ..*f }, sr); + return; + } + // Outside the area: the ordinary drawing (an even-odd clip of the view minus the area). + if area != view { + let mut outside = view.to_path(0.1); + outside.extend(area.to_path(0.1).iter()); + ctx.set_transform(Affine::IDENTITY); + ctx.set_fill_rule(peniko::Fill::EvenOdd); + ctx.push_clip_layer(&outside); + ctx.set_fill_rule(peniko::Fill::NonZero); + self.draw_spread(ctx, &Frame { opts: &rgb, ..*f }, sr); + ctx.pop_layer(); + } + // 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; let stats = self.stats; + let threads = self.threads; let mut pass = |plate| { - let opts = - RenderOptions { paper: false, background: None, page_shadow: false, blend_space_view: false, plate: Some(plate), ..f.opts.clone() }; + let opts = RenderOptions { plate: Some(plate), ..rgb.clone() }; crate::PLATE.with(|p| p.set(opts.plate)); - // Render filter layers directly in both ink passes, keeping their coverage on - // the same single-threaded path even when the outer viewport uses render workers. - let mut off = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); - self.draw_spread(&mut off, &Frame { opts: &opts, mt: false, ..*f }, sr); + 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); off.flush(); - let mut pm = vello_cpu::Pixmap::new(w, h); + let mut pm = vello_cpu::Pixmap::new(aw, ah); off.render(&mut pm, &mut self.resources); #[cfg(test)] { self.cmyk_passes += 1; + self.cmyk_pass_pixels = aw as usize * ah as usize; } pm }; let mut cmy = pass(4); let black = pass(3); + self.cmyk_ran = true; crate::PLATE.with(|p| p.set(f.opts.plate)); self.stats.items = (self.stats.items - stats.items) / 2 + stats.items; self.stats.glyphs = (self.stats.glyphs - stats.glyphs) / 2 + stats.glyphs; @@ -318,9 +396,10 @@ impl Renderer { let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * a).round() as u8); (c.r, c.g, c.b) = (r, g, b); } - ctx.set_transform(Affine::IDENTITY); + ctx.set_transform(Affine::translate((area.x0, area.y0))); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(cmy)), 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, aw as f64, ah as f64)); + ctx.set_transform(Affine::IDENTITY); } } @@ -475,6 +554,51 @@ mod tests { assert_eq!(renderer.cmyk_passes, 10); } + #[test] + fn cmyk_passes_cover_only_what_transparency_can_change() { + // A 200×140 view; one small Multiply object in a corner, a solid one far from it. + 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; + let image = draw(&doc, &mut renderer); + assert_eq!(renderer.cmyk_passes, 2); + assert!(renderer.cmyk_pass_pixels < 200 * 140 / 8, "{} px", renderer.cmyk_pass_pixels); + // The solid object is drawn as before, its colour exact. + let cyan = RenderOptions { blend_space_view: false, background: Some([180, 180, 180, 255]), ..Default::default() }; + let plain = renderer.render( + &doc, + &Cache::new(), + &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], + 200, + 140, + Affine::translate((30.0, 10.0)), + &cyan, + ); + assert_eq!(image.pixel(150, 100), plain.pixel(150, 100)); + // An object the area touches is drawn whole in the ink passes (no seam across it). + let mut renderer = Renderer::new(); + renderer.threads = 0; + rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(15.0, 15.0, 90.0, 60.0), "C=0 M=0 Y=100 K=0", DcBlend::Normal); + draw(&doc, &mut renderer); + assert!(renderer.cmyk_pass_pixels >= 75 * 45, "{} px", renderer.cmyk_pass_pixels); + } + + #[test] + fn the_view_is_kept_only_when_the_ink_passes_ran() { + let mut doc = document(); + doc.settings.blend_space = designcraft_doc::BlendSpace::Cmyk; + let id = rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(20.0, 20.0, 100.0, 100.0), "C=100 M=0 Y=0 K=0", DcBlend::Normal); + let mut renderer = Renderer::new(); + renderer.threads = 0; + draw(&doc, &mut renderer); + assert!(renderer.cmyk_view.is_none(), "nothing transparent: no ink passes, nothing kept"); + doc.item_mut(id).unwrap().blend = DcBlend::Multiply; + draw(&doc, &mut renderer); + assert!(renderer.cmyk_view.is_some()); + } + #[test] fn cmyk_channels_keep_the_same_alpha_through_transparency() { let mut doc = document(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 84b3a3667..c9e4c17f0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -220,8 +220,13 @@ pub struct Renderer { /// Composed stories looked up during the current render. stories: HashMap<(StoryId, Option), Arc>, cmyk_view: Option, + /// Whether the last render ran the CMYK ink passes (only then is its view worth keeping). + cmyk_ran: bool, #[cfg(test)] cmyk_passes: usize, + /// Pixels the last ink pass covered (tests). + #[cfg(test)] + cmyk_pass_pixels: usize, pub threads: u16, pub stats: FrameStats, } @@ -268,8 +273,11 @@ impl Renderer { glyphs: text::GlyphCache::default(), stories: HashMap::new(), cmyk_view: None, + cmyk_ran: false, #[cfg(test)] cmyk_passes: 0, + #[cfg(test)] + cmyk_pass_pixels: 0, threads: default_threads(), stats: FrameStats::default(), } @@ -309,11 +317,10 @@ impl Renderer { } // Release the old snapshot before rendering an edited document or resized viewport. self.cmyk_view = None; + self.cmyk_ran = false; let image = self.render_uncached(doc, cache, spreads, width, height, view, opts); - if opts.blend_space_view - && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk - && image.pixels.len() <= blendspace::CachedView::MAX_BYTES - { + // Kept only when the ink passes ran: other views render at their usual cost anyway. + if self.cmyk_ran && image.pixels.len() <= blendspace::CachedView::MAX_BYTES { self.cmyk_view = Some(blendspace::CachedView::new(doc, spreads, view, opts, &image, self.stats, self.threads)); } self.stats.micros = now().saturating_sub(start); From 7a30e5427e754e49e2f8a4f148db7ef96853c56f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 15:58:05 +0200 Subject: [PATCH 059/200] fix(render): radial gradients stretch with the object's box, as in InDesign InDesign draws a radial gradient in the object's bounding box: on a non-square frame it's an ellipse reaching the frame's sides, and a Gradient Swatch tool drag keeps those proportions. LYH Studio episode 6 (3:16) gives the left page's background a radial gradient: magenta in the middle, cyan out to every edge. DesignCraft drew a circle reaching only the longer side, so a portrait page stayed purple at its sides. - doc: `Fill::radial_ellipse(bounds)`: centre and radii (default: the box's centre and half its width and height; with a tool vector, its start, and its length measured with the box made square). - render: the radial paint is that circle, stretched by the paint transform; PDF: the shading's transform does the same. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 16 ++++++++++ crates/engine/src/cmd/style.rs | 37 +++++++++++++++++++++ crates/pdf/src/export.rs | 13 ++++---- crates/render/src/lib.rs | 13 ++++---- crates/render/tests/gradients.rs | 55 ++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 7 files changed, 122 insertions(+), 15 deletions(-) create mode 100644 crates/render/tests/gradients.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..6d2059781 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** · Radial gradients stretch with the object's box, as InDesign draws them: by default an ellipse reaching the frame's sides (it was a circle reaching only the longer side), and a Gradient Swatch tool drag keeps the box's proportions; on screen and in PDF. 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/doc/src/item.rs b/crates/doc/src/item.rs index 0a701998e..2962d374d 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -32,6 +32,22 @@ impl Default for Fill { } impl Fill { + /// A radial gradient's ellipse on an object whose bounds (in its own space) are `b`: centre and + /// horizontal and vertical radii. As in InDesign, a radial gradient stretches with the object's + /// box: by default it's centred and reaches the box's sides (an ellipse in a non-square box); + /// with a Gradient Swatch tool vector it's centred at the vector's start, and the vector's + /// length, measured with the box made square, sets its size. + pub fn radial_ellipse(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, f64, f64) { + let k = if b.width() > 1e-9 && b.height() > 1e-9 { b.height() / b.width() } else { 1.0 }; + match self.gradient_vector { + Some([x0, y0, x1, y1]) => { + 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), + } + } + 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 c4d20f099..39025c2af 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1751,3 +1751,40 @@ mod sample_color_tests { } } } + +#[cfg(test)] +mod radial_gradient_tests { + use serde_json::json; + + /// White at the side and the top of a 100×200 radial (black → white) rectangle at (50, 20) on + /// a 200×240 page, rendered at 1 px/pt. + fn sides(img: &designcraft_render::Rendered) -> (u8, u8, u8) { + (img.pixel(100, 120)[0], img.pixel(52, 120)[0], img.pixel(100, 22)[0]) + } + + #[test] + fn a_radial_gradient_is_the_objects_ellipse_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [50, 20, 150, 220], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "swatch": "[None]"})).unwrap(); + s.execute( + "object.gradient", + &json!({"ids": [id], "kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut r = designcraft_render::Renderer::new(); + let (c, side, top) = sides(&r.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(c < 30 && side > 220 && top > 220, "screen: centre {c}, side {side}, top {top}"); + // The PDF, placed back and drawn: the same ellipse. + 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": 240, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "radial.pdf", "x": 0, "y": 0, "width": 200})) + .unwrap(); + let d = p.doc().unwrap().doc.clone(); + 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}"); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..f75c652d0 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1015,11 +1015,10 @@ impl Exporter<'_> { let v = fill.gradient_vector; return Some(match kind { GradientKind::Radial => { - let (c, r) = match v { - Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), Vec2::new(x1 - x0, y1 - y0).hypot()), - None => (c, bounds.width().max(bounds.height()) / 2.0), - }; - let r = r.max(1e-3) as f32; + // A circle stretched into the object's ellipse, as on screen. + let (c, rx, ry) = fill.radial_ellipse(bounds); + let rx = rx.max(1e-3); + let stretch = Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2()); let (cx, cy) = (c.x as f32, c.y as f32); RadialGradient { fx: cx, @@ -1027,8 +1026,8 @@ impl Exporter<'_> { fr: 0.0, cx, cy, - cr: r, - transform: Transform::identity(), + cr: rx as f32, + transform: tf(stretch), spread_method: SpreadMethod::Pad, stops, anti_alias: false, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..6746e70d8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -886,15 +886,14 @@ fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_do } let c = bounds.center(); let grad = match (g.kind, fill.gradient_vector) { - (designcraft_color::GradientKind::Radial, Some([x0, y0, x1, y1])) => { - 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()) + // Radial: a circle stretched into the object's ellipse (the paint transform). + (designcraft_color::GradientKind::Radial, _) => { + let (c, rx, ry) = fill.radial_ellipse(bounds); + let rx = rx.max(1e-3); + ctx.set_paint_transform(Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2())); + peniko::Gradient::new_radial(c, rx 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; diff --git a/crates/render/tests/gradients.rs b/crates/render/tests/gradients.rs new file mode 100644 index 000000000..619d3103b --- /dev/null +++ b/crates/render/tests/gradients.rs @@ -0,0 +1,55 @@ +//! Gradient fills: InDesign stretches a radial gradient with the object's box. + +use designcraft_color::swatch::{Swatch, SwatchValue}; +use designcraft_color::{Color, Gradient, GradientKind, GradientStop}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, Vec2, shapes}; +use designcraft_render::{Placed, RenderOptions, Rendered, Renderer}; + +/// A 100×200 rectangle at (50, 20) filled with a black-to-white radial gradient. +fn portrait(vector: Option<[f64; 4]>) -> Document { + let mut d = Document::new(&NewDocument { width: 200.0, height: 240.0, facing_pages: false, ..Default::default() }); + let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 }; + d.swatches.push(Swatch { + name: "Radial".into(), + value: SwatchValue::Gradient { + gradient: Gradient { kind: GradientKind::Radial, stops: vec![stop(0.0, Color::BLACK), stop(1.0, Color::WHITE)] }, + }, + locked: false, + named: true, + hidden: false, + }); + let id = ItemId(d.alloc()); + let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(50.0, 20.0, 150.0, 220.0))); + it.fill = Fill { swatch: "Radial".into(), gradient_vector: vector, ..Fill::none() }; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + d +} + +fn render(d: &Document) -> Rendered { + let mut r = Renderer::new(); + r.threads = 0; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + r.render(d, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), xf: Affine::translate(Vec2::ZERO) }], 200, 240, Affine::IDENTITY, &opts) +} + +fn grey(img: &Rendered, x: u32, y: u32) -> u8 { + img.pixel(x, y)[0] +} + +#[test] +fn a_radial_gradient_stretches_with_its_object() { + // Centred, it reaches white at the sides and at the top and bottom alike (an ellipse). + let img = render(&portrait(None)); + assert!(grey(&img, 100, 120) < 20, "black at the centre: {}", grey(&img, 100, 120)); + let (side, top) = (grey(&img, 52, 120), grey(&img, 100, 22)); + assert!(side > 225 && top > 225, "white at the side ({side}) and the top ({top})"); + // Half-way to the side and half-way to the top: the same grey. + let (hs, ht) = (grey(&img, 75, 120), grey(&img, 100, 70)); + assert!(hs.abs_diff(ht) < 12, "half-way: side {hs}, top {ht}"); + // A Gradient Swatch tool vector to the right side: the same ellipse. + let dragged = render(&portrait(Some([100.0, 120.0, 150.0, 120.0]))); + assert!(grey(&dragged, 100, 70).abs_diff(ht) < 12 && grey(&dragged, 75, 120).abs_diff(hs) < 12); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..1a464e7d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -421,7 +421,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. - Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. -- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. +- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. - Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. From 029a911bf49b0162eb28137818ace58971f4fd77 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:11:39 +0200 Subject: [PATCH 060/200] EPUB/HTML: anchored objects are exported where they are anchored (#766) story_html left out each anchored object's mark and the object with it, and reading_order only sees page items, so an image anchored in a story was in neither the EPUB nor the HTML export. The story's text is now split at the object marks, and each anchored graphic is written in place: inline in the paragraph, an above-line object as a block span with its alignment. The graphic markup (and image or media file registration) is shared with free graphics (graphic_html). Fixes #766 --- crates/epub/src/lib.rs | 213 +++++++++++++++++++++++++++++++---------- 1 file changed, 160 insertions(+), 53 deletions(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 77b509d0b..ebb9d3de5 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -425,8 +425,14 @@ fn class_selector(tag: &str, class: &str) -> Option { Some(selector) } -/// One story as XHTML paragraphs. +/// One story as XHTML paragraphs (anchored objects left out; see [`story_html_with`]). pub fn story_html(doc: &Document, sid: StoryId) -> String { + story_html_with(doc, sid, &mut |_| String::new()) +} + +/// One story as XHTML paragraphs, each anchored object written where it is anchored as the +/// markup `object` returns for it. +fn story_html_with(doc: &Document, sid: StoryId, object: &mut dyn FnMut(&designcraft_doc::AnchoredObject) -> String) -> String { let Some(st) = doc.story(sid) else { return String::new() }; let (pc, cc) = (para_classes(&doc.styles), char_classes(&doc.styles)); let mut out = String::new(); @@ -451,18 +457,32 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { continue; } let raw = &st.text[a..b]; - let t = if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { - let sub: String = raw - .chars() - .map(|c| match designcraft_doc::vars::var_index(c) { - Some(i) => doc.variable_value(i, None).unwrap_or_default(), - None => c.to_string(), - }) - .collect(); - story_markup(&sub) - } else { - story_markup(raw) + // Text with variables substituted; each anchored object's markup in place of its mark. + let text = |seg: &str| { + if seg.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { + let sub: String = seg + .chars() + .map(|c| match designcraft_doc::vars::var_index(c) { + Some(i) => doc.variable_value(i, None).unwrap_or_default(), + None => c.to_string(), + }) + .collect(); + story_markup(&sub) + } else { + story_markup(seg) + } }; + let mut mark = st.text.get(..a).map_or(0, |t| t.matches(designcraft_doc::OBJECT_MARK).count()); + let mut t = String::new(); + for (i, seg) in raw.split(designcraft_doc::OBJECT_MARK).enumerate() { + if i > 0 { + if let Some(o) = st.objects.get(mark) { + t.push_str(&object(o)); + } + mark += 1; + } + t.push_str(&text(seg)); + } let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc); (tag, format!(" class=\"{}\"", xml_attr(&class))) @@ -563,6 +583,50 @@ fn ext(mime: &str) -> &'static str { } } +/// The markup for one graphic item: an ``, or an HTML5 player for video and sound (with +/// `true`), its file registered in `images` under the name `img_src` is given. `None` for an +/// item that isn't a graphic or whose asset has no data. +fn graphic_html<'a>( + doc: &'a Document, + it: &Item, + images: &mut BTreeMap, + img_src: &dyn Fn(&str, &str, &[u8]) -> String, +) -> Option<(String, bool)> { + let Content::Graphic(g) = &it.content else { return None }; + let a = doc.assets.get(&g.asset)?; + if a.data.is_empty() { + return None; + } + let mime = safe_mime(&a.mime); + // Video and sound: an HTML5 player (with the poster, when there is one). + if let Some(kind) = designcraft_doc::media_kind(mime) { + let name = format!("media/{}.{}", g.asset.0, ext(mime)); + images.insert(name.clone(), (mime, a.data.as_slice())); + let m = it.media.clone().unwrap_or_default(); + let mut attrs = String::new(); + for (on, attr) in [(m.controls, " controls=\"controls\""), (m.play_on_page_load, " autoplay=\"autoplay\""), (m.looping, " loop=\"loop\"")] { + if on { + attrs.push_str(attr); + } + } + if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) { + let poster_mime = safe_mime(&pa.mime); + let pn = format!("images/{}.{}", pa.id.0, ext(poster_mime)); + images.insert(pn.clone(), (poster_mime, pa.data.as_slice())); + if kind == "video" { + attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data)))); + } + } + let markup = format!("<{kind} src=\"{}\"{attrs}>{}", xml_attr(&img_src(&name, mime, &a.data)), xml_text(&a.name)); + return Some((markup, true)); + } + let name = format!("images/{}.{}", g.asset.0, ext(mime)); + images.insert(name.clone(), (mime, a.data.as_slice())); + // Alt text from Object Export Options, else the file name. + let alt = if it.alt_text.is_empty() { a.name.as_str() } else { it.alt_text.as_str() }; + Some((format!("\"{}\"/", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(alt)), false)) +} + /// The content in reading order as HTML sections and figures; `img_src` names each image /// (a package path, or a data URI for single-file HTML). Returns (body, images by name, table of /// contents). @@ -587,50 +651,38 @@ fn body_html<'a>( toc.push((anchor.clone(), first)); } } - let _ = write!(body, "
\n{}
\n", story_html(doc, *sid)); + // Anchored objects go where they are anchored: their graphics in the text, an + // above-line object as a block of its own. + let mut object = |o: &designcraft_doc::AnchoredObject| { + let mut out = String::new(); + o.item.walk(&mut |it| { + if let Some((markup, _)) = graphic_html(doc, it, &mut images, img_src) { + out.push_str(&markup); + } + }); + match &o.position { + designcraft_doc::AnchorPosition::AboveLine { align, .. } if !out.is_empty() => { + let align = match align { + designcraft_doc::anchored::AnchorAlign::Left => "left", + designcraft_doc::anchored::AnchorAlign::Center => "center", + designcraft_doc::anchored::AnchorAlign::Right => "right", + }; + format!("{out}") + } + _ => out, + } + }; + let _ = write!(body, "
\n{}
\n", story_html_with(doc, *sid, &mut object)); } Block::Image(iid) => { - let Some(Content::Graphic(g)) = doc.item(*iid).map(|i| &i.content) else { continue }; - let Some(a) = doc.assets.get(&g.asset) else { continue }; - if a.data.is_empty() { + let Some(it) = doc.item(*iid) else { continue }; + let Some((markup, media)) = graphic_html(doc, it, &mut images, img_src) else { continue }; + if media { + let _ = writeln!(body, "
{markup}
"); continue; } - let mime = safe_mime(&a.mime); - // Video and sound: an HTML5 player (with the poster, when there is one). - if let Some(kind) = designcraft_doc::media_kind(mime) { - let name = format!("media/{}.{}", g.asset.0, ext(mime)); - images.insert(name.clone(), (mime, a.data.as_slice())); - let m = doc.item(*iid).and_then(|i| i.media.clone()).unwrap_or_default(); - let mut attrs = String::new(); - for (on, attr) in - [(m.controls, " controls=\"controls\""), (m.play_on_page_load, " autoplay=\"autoplay\""), (m.looping, " loop=\"loop\"")] - { - if on { - attrs.push_str(attr); - } - } - if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) { - let poster_mime = safe_mime(&pa.mime); - let pn = format!("images/{}.{}", pa.id.0, ext(poster_mime)); - images.insert(pn.clone(), (poster_mime, pa.data.as_slice())); - if kind == "video" { - attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data)))); - } - } - let _ = writeln!( - body, - "
<{kind} src=\"{}\"{attrs}>{}
", - xml_attr(&img_src(&name, mime, &a.data)), - xml_text(&a.name) - ); - continue; - } - let name = format!("images/{}.{}", g.asset.0, ext(mime)); - images.insert(name.clone(), (mime, a.data.as_slice())); - // Alt text from Object Export Options, else the file name. - let alt = doc.item(*iid).map(|i| i.alt_text.as_str()).filter(|t| !t.is_empty()).unwrap_or(&a.name); // Object Export Options: alignment and a page break before. - let eo = doc.item(*iid).map(|i| i.export_options.clone()).unwrap_or_default(); + let eo = &it.export_options; let mut css = String::new(); if matches!(eo.align.as_str(), "left" | "center" | "right") { css.push_str(&format!("text-align:{};", eo.align)); @@ -639,8 +691,7 @@ fn body_html<'a>( css.push_str("page-break-before:always;break-before:page;"); } let style = if css.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&css)) }; - let _ = - writeln!(body, "\"{}\"/", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(alt)); + let _ = writeln!(body, "{markup}"); } } } @@ -788,6 +839,62 @@ mod tests { assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); } + /// #766: anchored graphics are exported where they are anchored (inline in the text, an + /// above-line object as a block of its own), and their images go into the package. + #[test] + fn anchored_objects_are_exported_in_place() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 200.0), lid, "AB\nCD", ParaFormat::default()).unwrap(); + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { + page: 0, + id: aid, + name: "dot.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(vec![7]), + pixels: Some((1, 1)), + }), + ); + let graphic = |d: &mut Document, alt: &str| { + let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + item.alt_text = alt.into(); + item.content = Content::Graphic(Graphic { + asset: aid, + size: (1.0, 1.0), + xf: Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + item + }; + let inline = designcraft_doc::AnchoredObject::new(graphic(&mut d, "inline"), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); + let above = designcraft_doc::AnchoredObject::new( + graphic(&mut d, "above"), + designcraft_doc::AnchorPosition::AboveLine { align: designcraft_doc::anchored::AnchorAlign::Center, space_before: 0.0, space_after: 0.0 }, + ); + let st = d.story_mut(sid).unwrap(); + st.insert_object(1, inline); + // Byte offsets: "A" (1), the mark (3), "B\nC" (3): the second object goes between C and D. + st.insert_object(7, above); + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let img = |alt: &str| format!("\"{alt}\"/", aid.0); + assert!(html.contains(&format!("A{}B

", img("inline"))), "{html}"); + assert!(html.contains(&format!("C{}D", img("above"))), "{html}"); + assert!(z.by_name(&format!("OEBPS/images/{}.png", aid.0)).is_ok(), "the image is in the package"); + let page = export_html(&d, &HtmlOptions::default()); + assert!(page.contains("alt=\"inline\"") && page.contains("alt=\"above\""), "{page}"); + // story_html on its own still leaves objects out. + assert!(!story_html(&d, sid).contains(" Date: Tue, 13 Oct 2026 16:16:00 +0200 Subject: [PATCH 061/200] EPUB/HTML: list paragraphs share one ul/ol, and pre lines one block (#767) story_html wrote every paragraph on its own, so li paragraphs had no list around them and each line of a pre style was its own
.
Consecutive li paragraphs now share one list: ol for a numbered style,
ul for a bulleted one, and an unmarked ul (list-style:none) when the
style has no list type of its own (typed bullets). Consecutive pre
paragraphs of one style (and direction) become one 
 with their
lines separated by newlines.

Fixes #767
---
 crates/epub/src/lib.rs | 100 +++++++++++++++++++++++++++++++++++++++--
 1 file changed, 97 insertions(+), 3 deletions(-)

diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs
index 77b509d0b..f500bcd47 100644
--- a/crates/epub/src/lib.rs
+++ b/crates/epub/src/lib.rs
@@ -430,13 +430,41 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
     let Some(st) = doc.story(sid) else { return String::new() };
     let (pc, cc) = (para_classes(&doc.styles), char_classes(&doc.styles));
     let mut out = String::new();
+    // Consecutive `li` paragraphs share one list (`ol` for a numbered style, `ul` for a bulleted
+    // one, an unmarked `ul` when the style has no list type of its own, e.g. typed bullets), and
+    // consecutive `pre` paragraphs of one style one block, their lines separated by newlines.
+    let mut list: Option<&str> = None;
+    let mut pre: Option<(String, bool)> = None;
     for (pi, r) in st.para_ranges().iter().enumerate() {
         let pf = &st.paras[pi];
         // Export Tagging: the style's element and class.
         let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc);
         let text_empty = st.text[r.clone()].trim().is_empty();
-        let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft;
-        let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" });
+        let props = doc.styles.resolve_para(pf).0;
+        let rtl = props.direction == designcraft_doc::TextDirection::RightToLeft;
+        let continues_pre = tag == "pre" && pre.as_ref() == Some(&(class.clone(), rtl));
+        if !continues_pre && pre.take().is_some() {
+            out.push_str("
\n"); + } + let list_tag = (tag == "li").then(|| match props.list_type { + designcraft_doc::ListType::Numbers => "ol", + designcraft_doc::ListType::Bullets => "ul", + designcraft_doc::ListType::None => "ul style=\"list-style:none\"", + }); + if list != list_tag { + if let Some(l) = list { + let _ = writeln!(out, "", l.split(' ').next().unwrap_or(l)); + } + if let Some(l) = list_tag { + let _ = writeln!(out, "<{l}>"); + } + list = list_tag; + } + if continues_pre { + out.push('\n'); + } else { + let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); + } if text_empty { out.push_str(" "); } @@ -477,7 +505,18 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}"); } } - let _ = writeln!(out, ""); + if tag == "pre" { + // Closed when the next paragraph isn't a line of the same block. + pre = Some((class, rtl)); + } else { + let _ = writeln!(out, ""); + } + } + if pre.is_some() { + out.push_str("
\n"); + } + if let Some(l) = list { + let _ = writeln!(out, "", l.split(' ').next().unwrap_or(l)); } out } @@ -788,6 +827,61 @@ mod tests { assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); } + fn st_typed(d: &mut Document, sid: StoryId) { + if let Some(st) = d.story_mut(sid) { + st.paras[8].style = "Typed".into(); + } + } + + /// #767: consecutive `li` paragraphs share one list (`ol` for a numbered style), and + /// consecutive `pre` paragraphs of one style one block with their lines on separate lines. + #[test] + fn lists_and_preformatted_blocks_are_grouped() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + for (name, tag, list) in [ + ("Bullet", "li", Some(designcraft_doc::ListType::Bullets)), + ("Number", "li", Some(designcraft_doc::ListType::Numbers)), + ("Typed", "li", None), + ("Code", "pre", None), + ] { + let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() }; + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para, + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); + } + let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed"; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in ["Bullet", "Bullet", "Number", "Number", "Code", "Code"].into_iter().enumerate() { + st.paras[i + 1].style = style.into(); + } + let html = story_html(&d, sid); + let li = |c: &str, t: &str| format!("
  • {t}
  • "); + assert!( + html.contains(&format!( + "
      \n{}\n{}\n
    \n
      \n{}\n{}\n
    \n", + li("bullet", "first"), + li("bullet", "second"), + li("number", "one"), + li("number", "two") + )), + "{html}" + ); + assert!(html.contains("
    x = 1\ny = 2
    \n

    End

    "), "{html}"); + assert_eq!(html.matches("\n
  • • typed
  • \n\n"), "{html}"); + } + #[test] fn styles_whose_names_slug_alike_keep_their_own_classes() { let mut d = Document::new(&NewDocument::default()); From 553a04311a0aa3eeb667a5ffbfa90d0a6086741f Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:16:35 +0200 Subject: [PATCH 062/200] EPUB/HTML: bool::then_some for the list tag (clippy) Refs #767 --- crates/epub/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index f500bcd47..06745d16c 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -446,7 +446,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { if !continues_pre && pre.take().is_some() { out.push_str("\n"); } - let list_tag = (tag == "li").then(|| match props.list_type { + let list_tag = (tag == "li").then_some(match props.list_type { designcraft_doc::ListType::Numbers => "ol", designcraft_doc::ListType::Bullets => "ul", designcraft_doc::ListType::None => "ul style=\"list-style:none\"", From 06762c3bfc80bf7523a9e7a3ff149801e0dd7ca6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:17:13 +0000 Subject: [PATCH 063/200] fix(idml): export keeps overridden parent items and their local copies' links (#725) --- ROADMAP.md | 1 + crates/idml/src/export.rs | 6 +++++- crates/idml/src/import.rs | 1 + crates/idml/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..0499ea8fd 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** · IDML export keeps parent item overrides: each page lists its overridden parent items and the local copy keeps its link to the parent item, so a reopened IDML file shows only the local copy (#725). 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/idml/src/export.rs b/crates/idml/src/export.rs index d01abe9e7..056789eb6 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1584,11 +1584,12 @@ impl<'a> Ex<'a> { for (i, pg) in sp.pages.iter().enumerate() { let name = if master { sp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default() } else { d.page_name(first_page + i) }; let applied = if master { based_on } else { pg.parent }; + let overrides: Vec = pg.overridden.iter().map(|i| uid(i.0)).collect(); let pe = El::new("Page") .attr("Self", uid(pg.id.0)) .attr("Name", name) .attr("AppliedMaster", applied.map(|s| uid(s.0)).unwrap_or_else(|| "n".into())) - .attr("OverrideList", "") + .attr("OverrideList", overrides.join(" ")) .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)); @@ -1620,6 +1621,9 @@ impl<'a> Ex<'a> { _ => "Rectangle", }; let mut el = El::new(tag).attr("Self", uid(it.id.0)); + if let Some(o) = it.overrides { + el.set("OverriddenPageItem", uid(o.0)); + } if let Content::Text(t) = &it.content && let Some(pt) = &t.options.path { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be7..f9f559936 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2093,6 +2093,7 @@ impl<'r> Importer<'r> { it.name = e.get("Name").filter(|n| *n != "$ID/").unwrap_or("").to_string(); it.locked = e.get("Locked") == Some("true"); it.hidden = e.get("Visible") == Some("false"); + it.overrides = e.get("OverriddenPageItem").and_then(|s| self.item_ids.get(s)).copied().filter(|i| self.is_parent_item(*i)); it.nonprinting = e.get("Nonprinting") == Some("true"); it.object_style = e .get("AppliedObjectStyle") diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index a2ee8f2d5..447baf838 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -553,6 +553,36 @@ fn round_trips_small_document() { assert!(back.swatch("Brand 30").is_some()); } +#[test] +fn round_trips_overridden_parent_items() { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let (ppi, ppage) = d.parent_page_for(0).unwrap(); + let px = d.parents[ppi].pages[ppage].x; + let pid = designcraft_doc::ItemId(d.alloc()); + let frame = designcraft_doc::Item::new(pid, lid, Shape::Rectangle, shapes::rectangle(Rect::new(px + 40.0, 40.0, px + 200.0, 120.0))); + d.insert_item(SpreadRef::Parent(ppi), frame.clone(), None).unwrap(); + // What layout.overrideParentItems leaves behind: a linked local copy, and the parent item listed on the page. + let mut copy = frame; + copy.id = designcraft_doc::ItemId(d.alloc()); + copy.xf = designcraft_geom::Affine::translate((d.spreads[0].pages[0].x - px, 0.0)) * copy.xf; + copy.overrides = Some(pid); + d.insert_item(SpreadRef::Doc(0), copy, None).unwrap(); + std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].overridden.push(pid); + + let back = import_idml(&export_idml(&d)).unwrap(); + let parent_ids: Vec<_> = back.parents[ppi].items.iter().map(|i| i.id).collect(); + assert_eq!(parent_ids.len(), 1); + let page = &back.spreads[0].pages[0]; + assert_eq!(page.overridden, parent_ids); + let local: Vec<_> = back.spreads[0].items.iter().collect(); + assert_eq!(local.len(), 1); + assert_eq!(local[0].overrides, Some(parent_ids[0])); + // Rendering skips overridden parent items, so page 1 shows only the local copy. + let shown_parent = back.parents[ppi].items.iter().filter(|i| !page.overridden.contains(&i.id)).count(); + assert_eq!(shown_parent + local.len(), 1); +} + #[test] fn uri_and_base64_helpers() { assert_eq!(import::uri_to_path("file:/a%20b/c.png"), "/a b/c.png"); From d6f4c0133a025b2a251baa6621995288af874b4e Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:18:20 +0000 Subject: [PATCH 064/200] fix(compose): text recomposes when the baseline grid, a wrap layer's visibility or section numbering changes (#718) --- ROADMAP.md | 1 + crates/compose/src/cache.rs | 26 ++++++++++++++++++++-- crates/compose/src/tests.rs | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..5fb535090 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** · Text recomposes when the document baseline grid changes, when a layer holding a text-wrap object is shown or hidden, or when a section's start number, numbering style, prefix or marker changes; the composition cache keyed only on the section count and item identities and kept the old layout. 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/compose/src/cache.rs b/crates/compose/src/cache.rs index 8aabbf0f1..a0d7e2c85 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -47,7 +47,18 @@ fn doc_signature(doc: &Document) -> u64 { } (Arc::as_ptr(&doc.styles) as usize).hash(&mut h); format!("{:?}", doc.text_variables).hash(&mut h); + sections_hash(doc).hash(&mut h); + h.finish() +} + +/// Everything page names and section markers are made from. +fn sections_hash(doc: &Document) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); doc.sections.len().hash(&mut h); + for s in &doc.sections { + (s.start, s.start_number, s.style as u8, &s.prefix, &s.marker, s.include_prefix).hash(&mut h); + } h.finish() } @@ -253,8 +264,17 @@ impl Cache { fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) { // The document's font scope: another document's fonts of the same name set it differently. - let mut sig = - vec![Arc::as_ptr(story) as usize, story.rev as usize, Arc::as_ptr(&doc.styles) as usize, doc.sections.len(), doc.font_scope as usize]; + // Sections name the pages under the frames (page-number and section markers). + let g = &doc.settings.baseline_grid; + let mut sig = vec![ + Arc::as_ptr(story) as usize, + story.rev as usize, + Arc::as_ptr(&doc.styles) as usize, + sections_hash(doc) as usize, + doc.font_scope as usize, + g.start.to_bits() as usize, + g.increment.to_bits() as usize, + ]; let mut keep = Vec::new(); let mut spreads = Vec::new(); for f in &story.frames { @@ -273,6 +293,8 @@ fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) for it in &sp.items { if it.wrap.mode != WrapMode::None { sig.push(Arc::as_ptr(it) as usize); + // Wrap objects on hidden layers don't wrap text. + sig.push(usize::from(doc.layer(it.layer).is_some_and(|l| l.visible))); keep.push(it.clone()); } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7a0e790d5..28682d689 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2929,3 +2929,47 @@ fn empty_paragraphs_get_no_bullet_or_number() { assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); } } + +#[test] +fn cache_recomposes_when_the_baseline_grid_changes() { + let para = ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }; + let (mut d, sid, _) = doc_with(LOREM, Rect::new(0.0, 0.0, 200.0, 600.0), para); + let cache = Cache::new(); + let a = cache.get(&d, sid, None); + d.settings.baseline_grid.increment = 30.0; + let b = cache.get(&d, sid, None); + assert!(!Arc::ptr_eq(&a, &b)); + let (la, lb) = (&a.frames[0].lines[1], &b.frames[0].lines[1]); + assert!((la.baseline - lb.baseline).abs() > 1.0, "{} {}", la.baseline, lb.baseline); +} + +#[test] +fn cache_recomposes_when_a_wrap_layer_is_hidden() { + let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = + designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 60.0))); + it.wrap.mode = WrapMode::BoundingBox; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = Cache::new(); + let a = cache.get(&d, sid, None); + assert!(a.frames[0].lines[0].x0 >= 120.0 - 1e-6); + d.layer_mut(lid).unwrap().visible = false; + let b = cache.get(&d, sid, None); + assert!(!Arc::ptr_eq(&a, &b)); + assert!(b.frames[0].lines[0].x0 < 1.0, "{}", b.frames[0].lines[0].x0); +} + +#[test] +fn cache_recomposes_page_numbers_when_a_section_is_renumbered() { + let text = story::PAGE_NUMBER.to_string(); + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + let cache = Cache::new(); + let a = cache.get(&d, sid, None); + assert_eq!(a.frames[0].lines[0].glyphs.len(), 1); + d.sections[0].start_number = Some(1000); + let b = cache.get(&d, sid, None); + assert!(!Arc::ptr_eq(&a, &b)); + assert_eq!(b.frames[0].lines[0].glyphs.len(), 4); +} From c91b9af16d9e26981b16cae25e3631d9adafebd4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:19:45 +0000 Subject: [PATCH 065/200] fix(epub): EPUB and HTML export include tables, footnotes and endnotes (#724) --- ROADMAP.md | 1 + crates/epub/src/lib.rs | 382 +++++++++++++++++++++++++++++++++---- docs/file-format-parity.md | 2 +- 3 files changed, 347 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..04addb11e 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** · Reflowable EPUB and HTML export include tables (header and footer rows, merged cells), footnotes (a linked note after the paragraph that refers to it) and endnotes (a linked section at the end of the book). 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/epub/src/lib.rs b/crates/epub/src/lib.rs index 77b509d0b..cedcbff76 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -4,6 +4,8 @@ //! frame); paragraph styles become CSS classes (`p.`), character styles become `span` //! classes, local overrides become inline styles. Placed graphics become `
    ` elements at //! their position in the reading order. Parent-page items (folios, running heads) are skipped. +//! Tables become `` elements, footnotes linked `aside`s after the paragraph that refers to +//! them, and endnotes a linked section at the end. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -11,7 +13,10 @@ use std::collections::BTreeMap; use std::fmt::Write as _; use std::io::Write; -use designcraft_doc::{Align, CharAttrs, CharProps, Content, Document, Item, ItemId, ParaProps, SpreadRef, StoryId, Styles, story}; +use designcraft_doc::{ + Align, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, FOOTNOTE_REF, Item, ItemId, ParaProps, SpreadRef, Story, StoryId, Styles, Table, + story, +}; #[derive(Clone, Debug)] pub struct EpubOptions { @@ -425,24 +430,96 @@ fn class_selector(tag: &str, class: &str) -> Option { Some(selector) } -/// One story as XHTML paragraphs. -pub fn story_html(doc: &Document, sid: StoryId) -> String { - let Some(st) = doc.story(sid) else { return String::new() }; - let (pc, cc) = (para_classes(&doc.styles), char_classes(&doc.styles)); - let mut out = String::new(); - for (pi, r) in st.para_ranges().iter().enumerate() { - let pf = &st.paras[pi]; - // Export Tagging: the style's element and class. - let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc); - let text_empty = st.text[r.clone()].trim().is_empty(); - let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; - let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); - if text_empty { - out.push_str(" "); +/// Nesting limit for stories inside stories (table cells, footnotes, tables in cells), so a deeply +/// nested or cyclic document can't recurse without bound. +const MAX_DEPTH: usize = 8; + +/// Writes stories with their tables and notes, numbering notes across the stories of one export. +struct StoryWriter<'a> { + doc: &'a Document, + pc: BTreeMap<&'a str, String>, + cc: BTreeMap<&'a str, String>, + /// EPUB structural semantics (`epub:type`); plain HTML doesn't declare the namespace. + epub: bool, + /// Notes written so far, for unique element ids. + footnote_ids: u32, + endnote_ids: u32, + /// Number shown on the next footnote and endnote of the current story. + next_footnote: u32, + next_endnote: u32, + /// Endnotes met so far: (number, list item markup). + endnotes: Vec<(u32, String)>, +} + +impl<'a> StoryWriter<'a> { + fn new(doc: &'a Document, epub: bool) -> Self { + StoryWriter { + doc, + pc: para_classes(&doc.styles), + cc: char_classes(&doc.styles), + epub, + footnote_ids: 0, + endnote_ids: 0, + next_footnote: 1, + next_endnote: 1, + endnotes: Vec::new(), } + } + + fn epub_type(&self, t: &str) -> String { + if self.epub { format!(" epub:type=\"{t}\"") } else { String::new() } + } + + /// One document story: paragraphs, tables, and each footnote after the paragraph that refers + /// to it. + fn story(&mut self, sid: StoryId) -> String { + let Some(st) = self.doc.story(sid) else { return String::new() }; + self.next_footnote = self.doc.footnote_start(sid); + self.next_endnote = self.doc.endnote_start(sid); + let mut out = String::new(); + self.paragraphs(st, 0, "", &mut out); + out + } + + /// `st`'s paragraphs. `lead` (a note's number) starts the first paragraph. + fn paragraphs(&mut self, st: &Story, depth: usize, lead: &str, out: &mut String) { + let doc = self.doc; + let mut lead = lead; + for (r, pf) in st.para_ranges().into_iter().zip(&st.paras) { + let Some(text) = st.text.get(r.clone()) else { continue }; + let table = pf.table.and_then(|id| st.tables.get(&id)).filter(|_| text.contains(story::TABLE_ANCHOR)); + let only_table = table.is_some() && text.chars().all(|c| c == story::TABLE_ANCHOR || c.is_whitespace()); + if !only_table || !lead.is_empty() { + // Export Tagging: the style's element and class. + let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &self.pc); + let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; + let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); + out.push_str(lead); + if lead.is_empty() && text.trim().is_empty() { + out.push_str(" "); + } + lead = ""; + let mut after = String::new(); + if !only_table { + self.runs(st, r, depth, out, &mut after); + } + let _ = writeln!(out, ""); + out.push_str(&after); + } + if let Some(t) = table + && depth < MAX_DEPTH + { + self.table(t, depth + 1, out); + } + } + } + + /// The runs of paragraph `para` of `st`; footnotes go to `after`. + fn runs(&mut self, st: &Story, para: std::ops::Range, depth: usize, out: &mut String, after: &mut String) { + let doc = self.doc; for (rr, f) in st.runs() { - let a = rr.start.max(r.start); - let b = rr.end.min(r.end); + let a = rr.start.max(para.start); + let b = rr.end.min(para.end); if a >= b { continue; } @@ -450,21 +527,26 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { if f.over.conditions.as_deref().is_some_and(|c| doc.conditions_hide(c)) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) { continue; } - let raw = &st.text[a..b]; - let t = if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { - let sub: String = raw - .chars() - .map(|c| match designcraft_doc::vars::var_index(c) { - Some(i) => doc.variable_value(i, None).unwrap_or_default(), - None => c.to_string(), - }) - .collect(); - story_markup(&sub) - } else { - story_markup(raw) - }; + let Some(raw) = st.text.get(a..b) else { continue }; + let mut t = String::new(); + let mut seg_start = 0; + for (i, c) in raw.char_indices() { + if c != FOOTNOTE_REF && c != ENDNOTE_REF { + continue; + } + t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default())); + seg_start = i + c.len_utf8(); + if depth < MAX_DEPTH { + if c == FOOTNOTE_REF { + self.footnote(st, a + i, depth, &mut t, after); + } else { + self.endnote(st, a + i, depth, &mut t); + } + } + } + t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default())); let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { - let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc); + let (tag, class) = export_mapping(&doc.styles, &f.style, true, &self.cc); (tag, format!(" class=\"{}\"", xml_attr(&class))) } else { ("span", String::new()) @@ -477,9 +559,139 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}"); } } - let _ = writeln!(out, ""); } - out + + /// The footnote whose reference is at byte `pos` of `st`: a note reference in `out`, the note + /// itself in `after`. + fn footnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String, after: &mut String) { + let Some(note) = st.notes.get(st.notes_before(pos)) else { return }; + let doc = self.doc; + let o = &doc.footnote_options; + let n = self.next_footnote; + self.next_footnote = n.saturating_add(1); + self.footnote_ids = self.footnote_ids.saturating_add(1); + let id = self.footnote_ids; + let _ = write!( + out, + "{}", + self.epub_type("noteref"), + xml_text(&o.label(n, false)) + ); + let lead = format!("{} ", xml_text(&o.label(n, true))); + let mut body = String::new(); + self.paragraphs(¬e.text, depth + 1, &lead, &mut body); + let _ = write!(after, "\n{body}\n", self.epub_type("footnote")); + } + + /// The endnote whose reference is at byte `pos` of `st`: a note reference in `out`; the note + /// joins the endnotes section. + fn endnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String) { + let Some(note) = st.endnotes.get(st.endnotes_before(pos)) else { return }; + let n = self.next_endnote; + self.next_endnote = n.saturating_add(1); + self.endnote_ids = self.endnote_ids.saturating_add(1); + let id = self.endnote_ids; + let label = xml_text(&self.doc.endnote_options.label(n)); + let _ = write!(out, "{label}", self.epub_type("noteref")); + let mut body = String::new(); + self.paragraphs(¬e.text, depth + 1, &format!("{label} "), &mut body); + let item = format!("\n{body}\n", self.epub_type("endnote")); + self.endnotes.push((n, item)); + } + + /// The endnotes met so far, in number order, as one section (empty when there are none). + fn endnotes_section(&mut self) -> String { + if self.endnotes.is_empty() { + return String::new(); + } + self.endnotes.sort_by_key(|(n, _)| *n); + let doc = self.doc; + let o = &doc.endnote_options; + let mut out = format!("\n", self.epub_type("endnotes")); + if !o.heading.trim().is_empty() { + let (tag, class) = export_mapping(&doc.styles, &o.heading_style, false, &self.pc); + let _ = writeln!(out, "<{tag} class=\"{}\">{}", xml_attr(&class), xml_text(&o.heading)); + } + out.push_str("
      \n"); + for (_, item) in &self.endnotes { + out.push_str(item); + } + out.push_str("
    \n\n"); + out + } + + /// A table: header rows as `thead`, footer rows as `tfoot`, merged cells as row and column + /// spans (kept within their row group). + fn table(&mut self, t: &Table, depth: usize, out: &mut String) { + let (nr, nc) = (t.nrows(), t.ncols()); + if nr.checked_mul(nc) != Some(t.cells.len()) { + return; + } + let (h, f) = (t.header_rows(), t.footer_rows()); + let body_end = nr.saturating_sub(f).max(h); + let mut covered = vec![false; t.cells.len()]; + out.push_str("
    \n"); + for (rows, group, cell_tag) in [(0..h, "thead", "th"), (h..body_end, "tbody", "td"), (body_end..nr, "tfoot", "td")] { + if rows.is_empty() { + continue; + } + let _ = writeln!(out, "<{group}>"); + for r in rows.clone() { + out.push_str(""); + for c in 0..nc { + let i = r * nc + c; + if covered.get(i).copied().unwrap_or(true) { + continue; + } + let Some(cell) = t.cells.get(i) else { continue }; + let rs = (cell.row_span.max(1) as usize).min(rows.end - r); + let cs = (cell.col_span.max(1) as usize).min(nc - c); + for rr in r..r + rs { + for cc in c..c + cs { + if let Some(slot) = covered.get_mut(rr * nc + cc) { + *slot = true; + } + } + } + let _ = write!(out, "<{cell_tag}"); + if rs > 1 { + let _ = write!(out, " rowspan=\"{rs}\""); + } + if cs > 1 { + let _ = write!(out, " colspan=\"{cs}\""); + } + out.push('>'); + self.paragraphs(&cell.text, depth, "", out); + let _ = writeln!(out, ""); + } + out.push_str("\n"); + } + let _ = writeln!(out, ""); + } + out.push_str("
    \n"); + } +} + +/// Run text with variables replaced by their values. +fn run_text(doc: &Document, raw: &str) -> String { + if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { + let sub: String = raw + .chars() + .map(|c| match designcraft_doc::vars::var_index(c) { + Some(i) => doc.variable_value(i, None).unwrap_or_default(), + None => c.to_string(), + }) + .collect(); + story_markup(&sub) + } else { + story_markup(raw) + } +} + +/// One story as XHTML: paragraphs, tables, and each footnote after the paragraph that refers to +/// it. Endnote references link to the endnotes section that the full export ends with. +pub fn story_html(doc: &Document, sid: StoryId) -> String { + StoryWriter::new(doc, true).story(sid) } /// Reading order of document content: (page, y, x) of each story's first frame and of graphics. @@ -569,14 +781,19 @@ fn ext(mime: &str) -> &'static str { #[allow(clippy::type_complexity)] fn body_html<'a>( doc: &'a Document, + epub: bool, img_src: &dyn Fn(&str, &str, &[u8]) -> String, ) -> (String, BTreeMap, Vec<(String, String)>) { let order = reading_order(doc); + let mut writer = StoryWriter::new(doc, epub); let mut body = String::new(); let mut images: BTreeMap = BTreeMap::new(); let mut toc: Vec<(String, String)> = Vec::new(); for (k, b) in order.iter().enumerate() { match b { + // The endnote frame's text is generated from the endnotes, which end the body as a + // linked section instead. + Block::Story(sid) if doc.endnote_story == Some(*sid) => {} Block::Story(sid) => { let anchor = format!("s{k}"); // First paragraph text as the TOC label. @@ -587,7 +804,7 @@ fn body_html<'a>( toc.push((anchor.clone(), first)); } } - let _ = write!(body, "
    \n{}
    \n", story_html(doc, *sid)); + let _ = write!(body, "
    \n{}
    \n", writer.story(*sid)); } Block::Image(iid) => { let Some(Content::Graphic(g)) = doc.item(*iid).map(|i| &i.content) else { continue }; @@ -644,6 +861,14 @@ fn body_html<'a>( } } } + let endnotes = writer.endnotes_section(); + if !endnotes.is_empty() { + let heading = &doc.endnote_options.heading; + if !heading.trim().is_empty() { + toc.push(("endnotes".into(), heading.clone())); + } + body.push_str(&endnotes); + } (body, images, toc) } @@ -660,7 +885,7 @@ pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let language = opts.language.as_deref().unwrap_or("en"); let lang = language_tag(language); - let (body, _, _) = body_html(doc, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))); + let (body, _, _) = body_html(doc, false, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))); format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", xml_text(&title), @@ -686,7 +911,7 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); - let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string()); + let (body, images, toc) = body_html(doc, true, &|name, _, _| name.to_string()); let lang = language_tag(&opts.language); let chapter = format!( "\n\n\n{}\n\n{body}\n\n", @@ -763,7 +988,7 @@ mod tests { use super::*; use designcraft_doc::build::NewDocument; use designcraft_doc::geom::{Affine, Rect, shapes}; - use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef}; + use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Story, StoryId, Table}; use std::io::Read; use std::sync::Arc; @@ -913,4 +1138,87 @@ mod tests { assert!(opf.contains("A & B")); assert!(opf.contains("media-type=\"image/png\"")); } + + /// "Body¹ text", a 2×2 table, then "Closing" with an endnote. + fn tables_and_notes() -> Document { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "Body text", ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert_note(4, "Foot note text", ParaFormat::default()); + let mut t = Table::new(1, 2, 2, 0, 0, 200.0); + for (r, c, s) in [(0, 0, "Cell A1"), (0, 1, "Cell B1"), (1, 0, "Cell A2"), (1, 1, "Cell B2")] { + t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default()); + } + let end = st.len(); + st.insert_table(end, t); + let end = st.len(); + st.insert(end, "Closing"); + let end = st.len(); + st.insert_endnote(end, "End note text", ParaFormat::default()); + d + } + + #[test] + fn tables_footnotes_and_endnotes_are_exported() { + let d = tables_and_notes(); + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let (t0, t1) = (html.find("").unwrap(), html.find("
    ").unwrap()); + for cell in ["Cell A1", "Cell B1", "Cell A2", "Cell B2"] { + let at = html.find(&format!("

    {cell}

    \n")).unwrap_or_else(|| panic!("{cell} in a cell:\n{html}")); + assert!(t0 < at && at < t1); + } + assert_eq!(html.matches("").count(), 2); + + let noteref = "Body1 text"; + assert!(html.contains(noteref), "footnote reference:\n{html}"); + let aside = html.find("").unwrap(); + assert!(html[aside..aside_end].contains("1 Foot note text")); + assert!(aside < t0, "the footnote follows its paragraph"); + + assert!(html.contains("Closing1")); + let section = html.find("
    ").unwrap(); + assert!(section > html.find("Closing").unwrap()); + assert!(html[section..].contains("
  • ")); + assert!(html[section..].contains("1 End note text")); + let mut nav = String::new(); + z.by_name("OEBPS/nav.xhtml").unwrap().read_to_string(&mut nav).unwrap(); + assert!(nav.contains("href=\"content.xhtml#endnotes\"")); + + let page = export_html(&d, &HtmlOptions::default()); + assert!(page.contains("
  • "); + if i > 0 { + out.push_str(""); + } + } + out +} + /// Reading order of document content: (page, y, x) of each story's first frame and of graphics. #[derive(Debug, Clone, PartialEq)] pub enum Block { @@ -570,24 +665,17 @@ fn ext(mime: &str) -> &'static str { fn body_html<'a>( doc: &'a Document, img_src: &dyn Fn(&str, &str, &[u8]) -> String, -) -> (String, BTreeMap, Vec<(String, String)>) { + heading_ids: bool, +) -> (String, BTreeMap, Vec<(String, String, u8)>) { let order = reading_order(doc); let mut body = String::new(); let mut images: BTreeMap = BTreeMap::new(); - let mut toc: Vec<(String, String)> = Vec::new(); + let toc = toc_entries(doc, &order); for (k, b) in order.iter().enumerate() { match b { Block::Story(sid) => { let anchor = format!("s{k}"); - // First paragraph text as the TOC label. - if let Some(st) = doc.story(*sid) { - let first: String = - st.text.split('\n').next().unwrap_or("").chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(60).collect(); - if !first.trim().is_empty() { - toc.push((anchor.clone(), first)); - } - } - let _ = write!(body, "
    \n{}
    \n", story_html(doc, *sid)); + let _ = write!(body, "
    \n{}
    \n", story_html_in(doc, *sid, heading_ids.then_some(anchor.as_str()))); } Block::Image(iid) => { let Some(Content::Graphic(g)) = doc.item(*iid).map(|i| &i.content) else { continue }; @@ -660,7 +748,7 @@ pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let language = opts.language.as_deref().unwrap_or("en"); let lang = language_tag(language); - let (body, _, _) = body_html(doc, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))); + let (body, _, _) = body_html(doc, &|_, mime, data| format!("data:{mime};base64,{}", base64(data)), false); format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", xml_text(&title), @@ -686,22 +774,20 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); - let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string()); + let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string(), true); let lang = language_tag(&opts.language); let chapter = format!( "\n\n\n{}\n\n{body}\n\n", xml_text(&title) ); - let mut nav_items = String::new(); - for (a, label) in &toc { - let _ = write!(nav_items, "
  • {}
  • ", xml_text(label)); - } + let mut nav_items = nav_list(&toc); if nav_items.is_empty() { nav_items = format!("
  • {}
  • ", xml_text(&title)); } let nav = format!( - "\n\n\n{}\n\n\n", - xml_text(&title) + "\n\n\n{}\n\n\n", + xml_text(&title), + xml_text(opts.toc_title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(contents_title(&opts.language))) ); let mut manifest = String::from( "\n\n\n", @@ -788,6 +874,60 @@ mod tests { assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); } + /// #769, #770: the navigation lists the h1–h3 headings (nested by level) and links to them, + /// with its title in the book's language; without headings, each story's first paragraph. + #[test] + fn navigation_follows_the_headings() { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + for (name, tag) in [("Chapter", "h1"), ("Section", "h2")] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); + } + let text = "One\nBody\nOne point one\nMore\nTwo\nText"; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in [(0, "Chapter"), (2, "Section"), (4, "Chapter")] { + st.paras[i].style = style.into(); + } + // A story that starts with body text adds nothing to the table of contents. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Text after the figure.", ParaFormat::default()).unwrap(); + let read = |d: &Document, opts: &EpubOptions, path: &str| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let nl = EpubOptions { language: "nl".into(), ..Default::default() }; + let nav = read(&d, &nl, "OEBPS/nav.xhtml"); + let link = |t: &str, l: &str| format!("{l}"); + let want = format!( + "

    Inhoud

    1. {}
      1. {}
    2. {}
    ", + link("s0-p0", "One"), + link("s0-p2", "One point one"), + link("s0-p4", "Two") + ); + assert!(nav.contains(&want), "{nav}"); + assert!(!nav.contains("Text after the figure"), "{nav}"); + let content = read(&d, &nl, "OEBPS/content.xhtml"); + assert!(content.contains("

    One

    ") && content.contains("

    In dit boek

    ")); + // Without headings: one entry per story, its first paragraph. + let mut plain = Document::new(&NewDocument::default()); + let lid = plain.default_layer(); + 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}"); + } + #[test] fn styles_whose_names_slug_alike_keep_their_own_classes() { let mut d = Document::new(&NewDocument::default()); @@ -902,6 +1042,7 @@ mod tests { language: "English (US)".into(), identifier: Some("notes&edition".into()), cover: None, + toc_title: None, }, ) .unwrap(); diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/export_tag_styles.rs index 47485b532..b25d31c36 100644 --- a/crates/epub/tests/export_tag_styles.rs +++ b/crates/epub/tests/export_tag_styles.rs @@ -55,7 +55,20 @@ fn package(doc: &Document) -> (String, String) { let opf = read("OEBPS/content.opf"); assert!(xhtml.contains("")); assert!(opf.contains("href=\"style.css\" media-type=\"text/css\"")); - (xhtml, css) + (without_heading_ids(&xhtml), css) +} + +/// The markup without the `id`s headings get as table-of-contents targets (` id="s0-p0"`). +fn without_heading_ids(html: &str) -> String { + let mut out = String::with_capacity(html.len()); + let mut rest = html; + while let Some(i) = rest.find(" id=\"s") { + out.push_str(&rest[..i]); + let after = &rest[i + 6..]; + rest = after.find('"').map_or("", |j| &after[j + 1..]); + } + out.push_str(rest); + out } fn rule<'a>(css: &'a str, selector: &str) -> &'a str { From 7be88b4484f18d9a9510337adb87e75a3c65abba Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:33:34 +0200 Subject: [PATCH 071/200] EPUB: real modified time, a stable unique identifier, and a cover image file (#771, #773) - dcterms:modified was always 2026-01-01T00:00:00Z. It is now the export time (from the command; on the web, and in the crate without one, the document's last save or creation time). - The default dc:identifier was urn:designcraft:, shared by books with the same title. It is now a urn:uuid derived from the title and the document's creation time: stable from export to export, different between documents. file.exportEpub takes identifier to set it (an ISBN, say). - file.exportEpub takes coverImage, a PNG or JPEG file used as the cover (cover-image); a JPEG is packaged as cover.jpg/image/jpeg. Fixes #771 Fixes #773 --- crates/engine/src/cmd/export.rs | 44 +++++++++++- crates/epub/src/lib.rs | 120 +++++++++++++++++++++++++++++--- 2 files changed, 151 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..0b4a91d32 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> { "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", has_doc, export_pdf), cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, - "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", + "{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", has_doc, export_epub), cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", @@ -130,13 +130,27 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> { let mut rr = designcraft_render::Renderer::new(); rr.threads = designcraft_render::default_threads(); let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; - let cover = (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(); + // 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}")))?), + 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 + && !(c.starts_with(b"\x89PNG") || c.starts_with(&[0xFF, 0xD8, 0xFF])) + { + return Err(crate::EngineError::Other("the cover image must be a PNG or JPEG file".into())); + } let opts = designcraft_epub::EpubOptions { title: p.get("title").and_then(Value::as_str).map(str::to_string), author: p.get("author").and_then(Value::as_str).map(str::to_string), language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), - identifier: None, + identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string), cover, + // The export time; on the web the document's own save time stands in. + #[cfg(not(target_arch = "wasm32"))] + modified: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()), + #[cfg(target_arch = "wasm32")] + modified: None, }; if fixed { let d = &st.doc; @@ -672,6 +686,30 @@ mod text_tests { assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}"); } + /// #771, #773: a cover image file and an identifier through the command; the package's + /// modified time is the export's. + #[test] + fn epub_cover_image_file_and_identifier() { + let dir = std::env::temp_dir().join(format!("dc-epub-cover-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let jpg = dir.join("cover.jpg"); + std::fs::write(&jpg, [0xFF, 0xD8, 0xFF, 0xE0, 0, 16]).unwrap(); + let bad = dir.join("cover.txt"); + std::fs::write(&bad, b"not an image").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).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!({"coverImage": jpg.to_str().unwrap(), "identifier": "urn:isbn:9780000000002"})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let opf = read(&mut z, "OEBPS/content.opf"); + assert!(opf.contains("<dc:identifier id=\"bookid\">urn:isbn:9780000000002</dc:identifier>"), "{opf}"); + assert!(opf.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\""), "{opf}"); + assert!(!opf.contains("2026-01-01T00:00:00Z"), "the export time, not a fixed date: {opf}"); + assert!(z.by_name("OEBPS/images/cover.jpg").is_ok()); + assert!(s.execute("file.exportEpub", &json!({"coverImage": bad.to_str().unwrap()})).is_err()); + std::fs::remove_dir_all(&dir).ok(); + } + #[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 77b509d0b..8de906466 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -20,8 +20,54 @@ pub struct EpubOptions { pub language: String, /// Stable identifier (urn:uuid:…); derived from the title when absent. pub identifier: Option<String>, - /// Cover image (PNG), shown first and marked as the book's cover. + /// Cover image (PNG or JPEG), shown first and marked as the book's cover. pub cover: Option<Vec<u8>>, + /// `dcterms:modified` (Unix seconds, UTC); the document's last save, else its creation, when + /// absent. + pub modified: Option<i64>, +} + +/// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`. +fn iso8601(t: i64) -> String { + let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); + // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 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 day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60) +} + +/// The package's `dcterms:modified`: the given time, else the document's last save or creation. +fn modified(doc: &Document, opts: &EpubOptions) -> String { + let t = opts.modified.or((doc.modified > 0).then_some(doc.modified)).or((doc.created > 0).then_some(doc.created)); + t.map_or_else(|| "2026-01-01T00:00:00Z".to_string(), iso8601) +} + +/// The book's identifier: the given one, else a `urn:uuid` derived from the title and the +/// document's creation time, so a book keeps its identifier from export to export and two books +/// with the same title don't share one. +fn identifier(doc: &Document, opts: &EpubOptions, title: &str) -> String { + if let Some(id) = opts.identifier.clone().filter(|i| !i.trim().is_empty()) { + return id; + } + // Two FNV-1a 64-bit hashes (different offsets) as 128 bits, shaped as a version-5-style UUID. + let seed = format!("{title}\n{}", doc.created); + let fnv = |offset: u64| seed.bytes().fold(offset, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)); + let (a, b) = (fnv(0xcbf2_9ce4_8422_2325), fnv(0x84222325_cbf29ce4)); + let hi = (a & 0xffff_ffff_ffff_0fff) | 0x5000; + let lo = (b & 0x3fff_ffff_ffff_ffff) | 0x8000_0000_0000_0000; + format!("urn:uuid:{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", hi >> 32, (hi >> 16) & 0xffff, hi & 0xffff, lo >> 48, lo & 0xffff_ffff_ffff) +} + +/// Package name and media type of a cover image: JPEG by its signature, else PNG. +fn cover_file(data: &[u8]) -> (&'static str, &'static str) { + if data.starts_with(&[0xFF, 0xD8, 0xFF]) { ("images/cover.jpg", "image/jpeg") } else { ("images/cover.png", "image/png") } } /// A page of a fixed-layout EPUB: its image (PNG), size in points and text (kept for search, @@ -40,7 +86,8 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let lang = language_tag(&opts.language); - let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); + let id = identifier(doc, opts, &title); + let modified = modified(doc, opts); let mut manifest = String::from("<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n"); let mut spine = String::new(); let mut files: Vec<(String, Vec<u8>)> = Vec::new(); @@ -70,7 +117,7 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions ); let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default(); let opf = format!( - "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\" prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">2026-01-01T00:00:00Z</meta>\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</spine>\n</package>\n", + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\" prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</spine>\n</package>\n", xml_text(&id), xml_text(&title) ); @@ -100,7 +147,7 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions impl Default for EpubOptions { fn default() -> Self { - EpubOptions { title: None, author: None, language: "en".into(), identifier: None, cover: None } + EpubOptions { title: None, author: None, language: "en".into(), identifier: None, cover: None, modified: None } } } @@ -685,7 +732,8 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr let io = |e: std::io::Error| EpubError(e.to_string()); let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); + let id = identifier(doc, opts, &title); + let modified = modified(doc, opts); let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string()); let lang = language_tag(&opts.language); let chapter = format!( @@ -710,13 +758,17 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr let _ = writeln!(manifest, "<item id=\"img{k}\" href=\"{}\" media-type=\"{}\"/>", xml_attr(name), xml_attr(mime)); } // Cover: an image page first in reading order. - if opts.cover.is_some() { - manifest.push_str("<item id=\"cover-img\" href=\"images/cover.png\" media-type=\"image/png\" properties=\"cover-image\"/>\n<item id=\"cover\" href=\"cover.xhtml\" media-type=\"application/xhtml+xml\"/>\n"); + if let Some(c) = &opts.cover { + let (name, mime) = cover_file(c); + let _ = writeln!( + manifest, + "<item id=\"cover-img\" href=\"{name}\" media-type=\"{mime}\" properties=\"cover-image\"/>\n<item id=\"cover\" href=\"cover.xhtml\" media-type=\"application/xhtml+xml\"/>" + ); } let cover_spine = if opts.cover.is_some() { "<itemref idref=\"cover\"/>\n" } else { "" }; let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default(); let opf = format!( - "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">2026-01-01T00:00:00Z</meta>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{cover_spine}<itemref idref=\"content\"/>\n</spine>\n</package>\n", + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{cover_spine}<itemref idref=\"content\"/>\n</spine>\n</package>\n", xml_text(&id), xml_text(&title) ); @@ -743,11 +795,12 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr z.write_all(data).map_err(io)?; } if let Some(c) = &opts.cover { - z.start_file("OEBPS/images/cover.png", stored).map_err(zerr)?; + z.start_file(format!("OEBPS/{}", cover_file(c).0), stored).map_err(zerr)?; z.write_all(c).map_err(io)?; let xhtml = format!( - "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}\n\"{}\"/\n\n", + "\n\n\n{}\n\"{}\"/\n\n", xml_text(&title), + cover_file(c).0, xml_attr(&title) ); z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?; @@ -788,6 +841,52 @@ mod tests { assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); } + #[test] + fn iso8601_timestamps() { + for (t, want) in [ + (0, "1970-01-01T00:00:00Z"), + (951_782_400, "2000-02-29T00:00:00Z"), + (1_791_891_000, "2026-10-13T11:30:00Z"), + (-86_400, "1969-12-31T00:00:00Z"), + ] { + assert_eq!(iso8601(t), want); + } + } + + /// #771, #773: dcterms:modified is a real time, the default identifier is a stable urn:uuid + /// that differs between documents with the same title, and a JPEG cover keeps its type. + #[test] + fn package_metadata_and_cover_image() { + let opf = |d: &Document, opts: &EpubOptions| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut s = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut s).unwrap(); + let names: Vec = z.file_names().map(str::to_string).collect(); + (s, names) + }; + let id_of = |opf: &str| opf.split("").nth(1).and_then(|r| r.split('<').next()).unwrap_or_default().to_string(); + let mut a = Document::new(&NewDocument::default()); + a.title = "Same".into(); + a.created = 1_700_000_000; + let mut b = a.clone(); + b.created = 1_700_000_001; + let (oa, _) = opf(&a, &EpubOptions::default()); + let (oa2, _) = opf(&a, &EpubOptions::default()); + let (ob, _) = opf(&b, &EpubOptions::default()); + assert!(id_of(&oa).starts_with("urn:uuid:") && id_of(&oa).len() == 45, "{}", id_of(&oa)); + assert_eq!(id_of(&oa), id_of(&oa2), "stable from export to export"); + assert_ne!(id_of(&oa), id_of(&ob), "not shared by two books with the same title"); + // Modified: the given time, else the document's save (or creation) time. + assert!(oa.contains("2023-11-14T22:13:20Z"), "{oa}"); + let at = EpubOptions { modified: Some(1_791_891_000), ..Default::default() }; + assert!(opf(&a, &at).0.contains("2026-10-13T11:30:00Z")); + // A JPEG cover is packaged as such. + let jpeg = EpubOptions { cover: Some(vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2]), ..Default::default() }; + let (o, names) = opf(&a, &jpeg); + assert!(o.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\" properties=\"cover-image\""), "{o}"); + assert!(names.contains(&"OEBPS/images/cover.jpg".to_string()), "{names:?}"); + } + #[test] fn styles_whose_names_slug_alike_keep_their_own_classes() { let mut d = Document::new(&NewDocument::default()); @@ -902,6 +1001,7 @@ mod tests { language: "English (US)".into(), identifier: Some("notes&edition".into()), cover: None, + modified: None, }, ) .unwrap(); From f420639276fda55245ebb680cfc9fb51098d4f2b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:35:36 +0200 Subject: [PATCH 072/200] EPUB/HTML: no file name as alt text, readable image names in the package (#774) - An image without alt text (Object Export Options) got its file name as alt, which a screen reader then reads aloud. It is now decorative (alt=""), and file.exportEpub lists those images in a new warnings array so they can be given alt text. - Images and media were packaged as images/.. They are now -., the name from the original file name (lowercase ASCII); the id keeps two files of the same name apart. Fixes #774 --- crates/engine/src/cmd/export.rs | 11 +++-- crates/epub/src/lib.rs | 76 ++++++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..7fa6ea3aa 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", has_doc, export_pdf), cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, - "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", + "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative", has_doc, export_epub), cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", @@ -157,13 +157,17 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { }; } let bytes = designcraft_epub::export_epub(epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; + let warnings: Vec = designcraft_epub::images_without_alt(epub_doc) + .into_iter() + .map(|name| format!("{name}: no alt text (Object Export Options); exported as decorative (alt=\"\")")) + .collect(); match p.get("path").and_then(Value::as_str) { Some(path) => { #[cfg(not(target_arch = "wasm32"))] crate::atomic::write_file(path, &bytes)?; - Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings})) } - None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), + None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})), } } @@ -659,6 +663,7 @@ mod text_tests { s.execute("file.new", &json!({"pages": 2})).unwrap(); s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Page one words", "caret": false})).unwrap(); let r = s.execute("file.exportEpub", &json!({"cover": true})).unwrap(); + assert_eq!(r["warnings"], json!([]), "no images, nothing to report"); let mut z = unzip(r["base64"].as_str().unwrap()); let opf = read(&mut z, "OEBPS/content.opf"); assert!(opf.contains("cover-image") && opf.contains("")); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 77b509d0b..41959af51 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -544,6 +544,17 @@ pub fn reading_order(doc: &Document) -> Vec { keyed.into_iter().map(|(_, b)| b).collect() } +/// Package path of an asset: `{dir}/{name}-{id}.{ext}`, the name from its original file name +/// (lowercase ASCII, so the package stays readable) and the id keeping two assets of the same +/// name apart. +fn asset_path(dir: &str, a: &designcraft_doc::Asset, mime: &str) -> String { + let stem = a.name.rsplit(['/', '\\']).next().unwrap_or(""); + let stem = stem.rsplit_once('.').map_or(stem, |(s, _)| s); + let name: String = stem.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); + let name: String = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-").chars().take(40).collect(); + if name.is_empty() { format!("{dir}/{}.{}", a.id.0, ext(mime)) } else { format!("{dir}/{name}-{}.{}", a.id.0, ext(mime)) } +} + fn ext(mime: &str) -> &'static str { match mime { "image/jpeg" => "jpg", @@ -598,7 +609,7 @@ fn body_html<'a>( let mime = safe_mime(&a.mime); // Video and sound: an HTML5 player (with the poster, when there is one). if let Some(kind) = designcraft_doc::media_kind(mime) { - let name = format!("media/{}.{}", g.asset.0, ext(mime)); + let name = asset_path("media", a, mime); images.insert(name.clone(), (mime, a.data.as_slice())); let m = doc.item(*iid).and_then(|i| i.media.clone()).unwrap_or_default(); let mut attrs = String::new(); @@ -611,7 +622,7 @@ fn body_html<'a>( } if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) { let poster_mime = safe_mime(&pa.mime); - let pn = format!("images/{}.{}", pa.id.0, ext(poster_mime)); + let pn = asset_path("images", pa, poster_mime); images.insert(pn.clone(), (poster_mime, pa.data.as_slice())); if kind == "video" { attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data)))); @@ -625,10 +636,11 @@ fn body_html<'a>( ); continue; } - let name = format!("images/{}.{}", g.asset.0, ext(mime)); + let name = asset_path("images", a, mime); images.insert(name.clone(), (mime, a.data.as_slice())); - // Alt text from Object Export Options, else the file name. - let alt = doc.item(*iid).map(|i| i.alt_text.as_str()).filter(|t| !t.is_empty()).unwrap_or(&a.name); + // Alt text from Object Export Options; without one the image counts as decorative + // (`alt=""`): a file name read aloud helps nobody. + let alt = doc.item(*iid).map_or("", |i| i.alt_text.as_str()); // Object Export Options: alignment and a page break before. let eo = doc.item(*iid).map(|i| i.export_options.clone()).unwrap_or_default(); let mut css = String::new(); @@ -647,6 +659,22 @@ fn body_html<'a>( (body, images, toc) } +/// Placed images (not video or sound) without alt text, by asset name: they are exported as +/// decorative (`alt=""`), which an export can report. +pub fn images_without_alt(doc: &Document) -> Vec { + let mut out = Vec::new(); + for b in reading_order(doc) { + let Block::Image(iid) = b else { continue }; + let Some(it) = doc.item(iid) else { continue }; + let Content::Graphic(g) = &it.content else { continue }; + let Some(a) = doc.assets.get(&g.asset) else { continue }; + if !a.data.is_empty() && designcraft_doc::media_kind(safe_mime(&a.mime)).is_none() && it.alt_text.trim().is_empty() { + out.push(a.name.clone()); + } + } + out +} + /// Options for [`export_html`]. #[derive(Clone, Debug, Default)] pub struct HtmlOptions { @@ -845,6 +873,44 @@ mod tests { assert_eq!(base64(b"M"), "TQ=="); } + /// #774: an image without alt text is decorative (`alt=""`) and reported, and package names + /// come from the original file names. + #[test] + fn images_get_readable_names_and_no_file_name_alt() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let place = |d: &mut Document, name: &str, mime: &str, alt: &str, y: f64| { + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { page: 0, id: aid, name: name.into(), mime: mime.into(), link: None, data: Arc::new(vec![1]), pixels: Some((1, 1)) }), + ); + let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0))); + item.alt_text = alt.into(); + item.content = Content::Graphic(Graphic { + asset: aid, + size: (1.0, 1.0), + xf: Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + aid.0 + }; + let a = place(&mut d, "/tmp/Anim Frame (1).GIF", "image/gif", "", 36.0); + let b = place(&mut d, "photo.jpg", "image/jpeg", "A heron", 120.0); + let c = place(&mut d, "???.png", "image/png", "Mark", 200.0); + assert_eq!(images_without_alt(&d), vec!["/tmp/Anim Frame (1).GIF".to_string()]); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut x = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut x).unwrap(); + assert!(x.contains(&format!("\"\"/")), "{x}"); + assert!(x.contains(&format!("\"A")), "{x}"); + assert!(x.contains(&format!("\"Mark\"/")), "{x}"); + assert!(z.by_name(&format!("OEBPS/images/anim-frame-1-{a}.gif")).is_ok()); + } + #[test] fn metadata_is_serialized_for_its_output_context() { let mut d = Document::new(&NewDocument::default()); From 6fafa9b0a3dba97ec5d1f83032597834e9bbc0fb Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:41:08 +0200 Subject: [PATCH 073/200] EPUB/HTML: generic font fallback from the font's class, and optional font embedding (#775) - Every font-family fell back to serif, sans-serif typefaces included. The fallback now comes from the font's classification (PANOSE, OS/2 class, name: serif, sans-serif, monospace, cursive, fantasy), passed in by the engine; without one, from the family name. - file.exportEpub takes embedFonts: the faces the stylesheet names are packaged (OEBPS/fonts, @font-face, manifest items with font/ttf, otf, woff or woff2). Faces that aren't available, whose licence (OS/2 fsType) forbids embedding, or that sit in a font collection are left out and listed in the new warnings array. Fonts are packaged whole; subsetting is a follow-up. Fixes #775 --- crates/engine/src/cmd/export.rs | 91 ++++++++++++++- crates/epub/src/lib.rs | 200 ++++++++++++++++++++++++++++++-- 2 files changed, 277 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..7975c6842 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", has_doc, export_pdf), cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, - "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", + "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings})", has_doc, export_epub), cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", @@ -53,8 +53,11 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result { fn export_html(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let opts = - designcraft_epub::HtmlOptions { title: str_param(p, "title").map(str::to_string), language: str_param(p, "language").map(str::to_string) }; + let opts = designcraft_epub::HtmlOptions { + title: str_param(p, "title").map(str::to_string), + language: str_param(p, "language").map(str::to_string), + generic_families: generic_families(&st.doc), + }; let text = designcraft_epub::export_html(&st.doc, &opts); match str_param(p, "path") { Some(path) => { @@ -120,6 +123,49 @@ fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, out } +/// The CSS generic family of each font family the EPUB/HTML stylesheet names, from the fonts' +/// classification (PANOSE, OS/2 class, name), for the `font-family` fallbacks. +fn generic_families(d: &designcraft_doc::Document) -> std::collections::BTreeMap { + use designcraft_fonts::FontClass; + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); + let mut out = std::collections::BTreeMap::new(); + for (family, _) in designcraft_epub::stylesheet_fonts(d) { + if out.contains_key(&family) { + continue; + } + let generic = match db.family_kind(&family).map(|(k, _)| k.class) { + Some(FontClass::Serif | FontClass::SlabSerif) => "serif", + Some(FontClass::SansSerif) => "sans-serif", + Some(FontClass::Monospaced) => "monospace", + Some(FontClass::Script) => "cursive", + Some(FontClass::Decorative) => "fantasy", + Some(FontClass::Symbol | FontClass::Other) | None => continue, + }; + out.insert(family, generic.to_string()); + } + out +} + +/// The faces the EPUB stylesheet names, to package with it; `warnings` gets the ones left out +/// (not available, a licence that forbids embedding, or part of a font collection). +fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec) -> Vec { + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); + let mut out = Vec::new(); + for (family, style) in designcraft_epub::stylesheet_fonts(d) { + 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() { + 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")); + } else { + out.push(designcraft_epub::EmbeddedFont { family, style: face.style.clone(), data: face.data().to_vec() }); + } + } + out +} + fn export_epub(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; // Object Export Options › Rasterize: those objects go in as images. @@ -130,6 +176,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { let mut rr = designcraft_render::Renderer::new(); rr.threads = designcraft_render::default_threads(); let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; + let mut warnings: Vec = Vec::new(); let cover = (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(); let opts = designcraft_epub::EpubOptions { title: p.get("title").and_then(Value::as_str).map(str::to_string), @@ -137,6 +184,12 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), identifier: None, cover, + generic_families: generic_families(&st.doc), + fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed { + embedded_fonts(&st.doc, &mut warnings) + } else { + Vec::new() + }, }; if fixed { let d = &st.doc; @@ -161,9 +214,9 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { Some(path) => { #[cfg(not(target_arch = "wasm32"))] crate::atomic::write_file(path, &bytes)?; - Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings})) } - None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), + None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})), } } @@ -672,6 +725,34 @@ mod text_tests { assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}"); } + /// #775: the fallback follows the font's class, and embedFonts packages the stylesheet's fonts + /// (and reports those it can't). + #[test] + fn epub_font_fallbacks_and_embedding() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Sans", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Regular"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Gone", "chars": {"fontFamily": "No Such Font Family", "fontStyle": "Regular"}})) + .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!({})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let css = read(&mut z, "OEBPS/style.css"); + assert!(css.contains("font-family: \"Source Sans 3\", sans-serif;"), "{css}"); + assert!(!css.contains("@font-face") && r["warnings"] == json!([]), "nothing embedded unless asked"); + 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("@font-face { font-family: \"Source Sans 3\"; src: url(\"fonts/"), "{css}"); + let opf = read(&mut z, "OEBPS/content.opf"); + let font = opf.split("href=\"fonts/").nth(1).and_then(|r| r.split('"').next()).map(|n| format!("OEBPS/fonts/{n}")); + assert!(font.as_ref().is_some_and(|f| z.by_name(f).is_ok_and(|e| e.size() > 1000)), "{opf}"); + let warnings = r["warnings"].as_array().unwrap(); + assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("No Such Font Family")), "{warnings:?}"); + let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(html.contains("\"Source Sans 3\", sans-serif;")); + } + #[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 77b509d0b..b2c73b532 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -22,6 +22,93 @@ pub struct EpubOptions { pub identifier: Option, /// Cover image (PNG), shown first and marked as the book's cover. pub cover: Option>, + /// Generic CSS family (`serif`, `sans-serif`, `monospace`, `cursive`, `fantasy`) per font + /// family, from the fonts' classification; families not in it are guessed from their name. + pub generic_families: BTreeMap, + /// Font files packaged with the book and declared with `@font-face`. + pub fonts: Vec, +} + +/// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…). +#[derive(Clone, Debug)] +pub struct EmbeddedFont { + pub family: String, + pub style: String, + pub data: Vec, +} + +/// The `(family, style)` pairs the stylesheet names, one each, in style order: the fonts an +/// export can embed. +pub fn stylesheet_fonts(doc: &Document) -> Vec<(String, String)> { + let st: &Styles = &doc.styles; + let mut out: Vec<(String, String)> = Vec::new(); + for ps in &st.paragraph { + if ps.name == designcraft_doc::NO_PARA_STYLE { + continue; + } + let (_, cp) = st.resolve_para_style(&ps.name); + let f = (cp.font_family.clone(), cp.font_style.clone()); + if !f.0.is_empty() && !out.contains(&f) { + out.push(f); + } + } + out +} + +/// The CSS generic family for `family`: the known one, else a guess from the name +/// (`… Mono`/`Courier`/`… Code` → monospace, `… Sans`/`Gothic`/`Grotesk`/`Helvetica`/`Arial`/ +/// `Verdana` → sans-serif), else serif. +fn generic_family<'a>(family: &str, known: &'a BTreeMap) -> &'a str { + const GENERIC: [&str; 6] = ["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"]; + if let Some(g) = known.get(family).and_then(|g| GENERIC.iter().find(|x| **x == g.as_str())) { + return g; + } + let f = family.to_lowercase(); + let has = |words: &[&str]| words.iter().any(|w| f.contains(w)); + if has(&["mono", "courier", " code", "consol"]) { + "monospace" + } else if has(&["sans", "gothic", "grotesk", "grotesque", "helvetica", "arial", "verdana"]) { + "sans-serif" + } else { + "serif" + } +} + +/// CSS weight and style of a style name (`Semibold Italic` → 600, italic). +fn css_weight_style(style: &str) -> (u16, &'static str) { + let s = style.to_lowercase().replace([' ', '-'], ""); + let weight = [ + ("thin", 100), + ("extralight", 200), + ("ultralight", 200), + ("light", 300), + ("medium", 500), + ("semibold", 600), + ("demibold", 600), + ("extrabold", 800), + ("ultrabold", 800), + ("bold", 700), + ("black", 900), + ("heavy", 900), + ] + .iter() + .find(|(n, _)| s.contains(n)) + .map_or(400, |(_, w)| *w); + (weight, if s.contains("italic") || s.contains("oblique") { "italic" } else { "normal" }) +} + +/// Package path and media type of an embedded font (`fonts/{k}-{family}-{style}.{ext}`). +fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) { + let (ext, mime) = match f.data.get(..4) { + Some(b"OTTO") => ("otf", "font/otf"), + Some(b"wOFF") => ("woff", "font/woff"), + Some(b"wOF2") => ("woff2", "font/woff2"), + _ => ("ttf", "font/ttf"), + }; + let name: String = + format!("{}-{}", f.family, f.style).chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); + let name = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-"); + (format!("fonts/{k}-{name}.{ext}"), mime) } /// A page of a fixed-layout EPUB: its image (PNG), size in points and text (kept for search, @@ -100,7 +187,15 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions impl Default for EpubOptions { fn default() -> Self { - EpubOptions { title: None, author: None, language: "en".into(), identifier: None, cover: None } + EpubOptions { + title: None, + author: None, + language: "en".into(), + identifier: None, + cover: None, + generic_families: BTreeMap::new(), + fonts: Vec::new(), + } } } @@ -246,8 +341,8 @@ fn hex(doc: &Document, swatch: &str, tint: f32) -> Option { Some(format!("#{r:02x}{g:02x}{b:02x}")) } -fn char_css(doc: &Document, c: &CharProps, base_size: f64, out: &mut String) { - let _ = write!(out, "font-family: \"{}\", serif; ", css_string(&c.font_family)); +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; "); @@ -303,10 +398,27 @@ fn para_css(p: &ParaProps, base_size: f64, line_px: f64, out: &mut String) { /// The stylesheet for all paragraph and character styles. pub fn stylesheet(doc: &Document) -> String { + stylesheet_with(doc, &BTreeMap::new(), &[]) +} + +/// [`stylesheet`] with the generic families to fall back on and `@font-face` rules for the +/// embedded fonts (packaged as [`font_file`] names them). +fn stylesheet_with(doc: &Document, generics: &BTreeMap, fonts: &[EmbeddedFont]) -> String { let st: &Styles = &doc.styles; + let mut faces = String::new(); + for (k, f) in fonts.iter().enumerate() { + let (path, _) = font_file(k, f); + let (weight, style) = css_weight_style(&f.style); + let _ = writeln!( + faces, + "@font-face {{ font-family: \"{}\"; src: url(\"{path}\"); font-weight: {weight}; font-style: {style}; }}", + css_string(&f.family) + ); + } let (_, basic) = st.resolve_para_style(story::BASIC_PARAGRAPH); let base = basic.size.max(1.0); - let mut css = String::from( + let mut css = faces; + css.push_str( "body { margin: 0 5%; font-size: 1em; }\nfigure { margin: 1em 0; text-align: center; }\nfigure img { max-width: 100%; }\nh1, h2, h3 { font-size: inherit; margin: 0; }\n", ); let (pc, cc) = (para_classes(st), char_classes(st)); @@ -321,7 +433,7 @@ pub fn stylesheet(doc: &Document) -> String { }; let mut rule = String::new(); para_css(&pp, base, lead, &mut rule); - char_css(doc, &cp, base, &mut rule); + char_css(doc, &cp, base, generics, &mut rule); let (tag, class) = export_mapping(st, &ps.name, false, &pc); if let Some(selector) = class_selector(tag, &class) { let _ = writeln!(css, "{selector} {{ {rule}}}"); @@ -652,6 +764,8 @@ fn body_html<'a>( pub struct HtmlOptions { pub title: Option, pub language: Option, + /// Generic CSS family per font family (see [`EpubOptions::generic_families`]). + pub generic_families: BTreeMap, } /// File › Export › HTML: one self-contained page (styles inline, images as data URIs) with the @@ -664,7 +778,7 @@ pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", xml_text(&title), - stylesheet(doc) + stylesheet_with(doc, &opts.generic_families, &[]) ) } @@ -709,6 +823,10 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr for (k, (name, (mime, _))) in images.iter().enumerate() { let _ = writeln!(manifest, "", xml_attr(name), xml_attr(mime)); } + for (k, f) in opts.fonts.iter().enumerate() { + let (name, mime) = font_file(k, f); + let _ = writeln!(manifest, "", xml_attr(&name)); + } // Cover: an image page first in reading order. if opts.cover.is_some() { manifest.push_str("\n\n"); @@ -732,7 +850,7 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr ("META-INF/container.xml", container.as_bytes()), ("OEBPS/content.opf", opf.as_bytes()), ("OEBPS/nav.xhtml", nav.as_bytes()), - ("OEBPS/style.css", stylesheet(doc).as_bytes()), + ("OEBPS/style.css", stylesheet_with(doc, &opts.generic_families, &opts.fonts).as_bytes()), ("OEBPS/content.xhtml", chapter.as_bytes()), ] { z.start_file(name, deflate).map_err(zerr)?; @@ -742,6 +860,10 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?; z.write_all(data).map_err(io)?; } + for (k, f) in opts.fonts.iter().enumerate() { + z.start_file(format!("OEBPS/{}", font_file(k, f).0), deflate).map_err(zerr)?; + z.write_all(&f.data).map_err(io)?; + } if let Some(c) = &opts.cover { z.start_file("OEBPS/images/cover.png", stored).map_err(zerr)?; z.write_all(c).map_err(io)?; @@ -837,7 +959,7 @@ mod tests { 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, "Hello ", ParaFormat::default()).unwrap(); - let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None }); + let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None, ..Default::default() }); assert!(html.starts_with("") && html.contains("T") && html.contains("Hello <web>")); assert!(html.contains("p.basic-paragraph"), "styles inline"); assert_eq!(base64(b"Man"), "TWFu"); @@ -845,6 +967,65 @@ mod tests { assert_eq!(base64(b"M"), "TQ=="); } + /// #775: the generic fallback follows the font, and embedded fonts are packaged and declared. + #[test] + fn generic_fallbacks_and_embedded_fonts() { + let none = BTreeMap::new(); + for (family, want) in [ + ("Source Serif 4", "serif"), + ("Source Sans 3", "sans-serif"), + ("Microsoft Sans Serif", "sans-serif"), + ("JetBrains Mono", "monospace"), + ("Courier New", "monospace"), + ("Franklin Gothic", "sans-serif"), + ("Garamond", "serif"), + ] { + assert_eq!(generic_family(family, &none), want, "{family}"); + } + let known: BTreeMap = + [("Montserrat".to_string(), "sans-serif".to_string()), ("Odd".to_string(), "nonsense".to_string())].into(); + assert_eq!(generic_family("Montserrat", &known), "sans-serif"); + assert_eq!(generic_family("Odd", &known), "serif", "only CSS generic families are used"); + assert_eq!(css_weight_style("Semibold Italic"), (600, "italic")); + 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")); + + 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, "Hello", ParaFormat::default()).unwrap(); + if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { + basic.chars.font_family = Some("Montserrat".into()); + basic.chars.font_style = Some("Bold Italic".into()); + } + assert_eq!(stylesheet_fonts(&d), vec![("Montserrat".to_string(), "Bold Italic".to_string())]); + let opts = EpubOptions { + generic_families: known, + fonts: vec![EmbeddedFont { family: "Montserrat".into(), style: "Bold Italic".into(), data: b"OTTO font".to_vec() }], + ..Default::default() + }; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &opts).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let css = read("OEBPS/style.css"); + assert!( + css.starts_with( + "@font-face { font-family: \"Montserrat\"; src: url(\"fonts/0-montserrat-bold-italic.otf\"); font-weight: 700; font-style: italic; }" + ), + "{css}" + ); + assert!(css.contains("font-family: \"Montserrat\", sans-serif;"), "{css}"); + let opf = read("OEBPS/content.opf"); + assert!(opf.contains(""), "{opf}"); + assert_eq!(read("OEBPS/fonts/0-montserrat-bold-italic.otf"), "OTTO font"); + // HTML takes the generic families too, and embeds nothing. + let html = export_html(&d, &HtmlOptions { generic_families: opts.generic_families.clone(), ..Default::default() }); + assert!(html.contains("\"Montserrat\", sans-serif;") && !html.contains("@font-face"), "{html}"); + } + #[test] fn metadata_is_serialized_for_its_output_context() { let mut d = Document::new(&NewDocument::default()); @@ -885,7 +1066,7 @@ mod tests { let story = story_html(&d, sid); assert!(story.contains("

    A&B <C> "D"

    ")); - let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()) }); + let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()), ..Default::default() }); assert!(html.contains("")); assert!(html.contains("Field Notes & <Edition>")); assert!(html.contains("alt=\"Cover "A" & B\"")); @@ -902,6 +1083,7 @@ mod tests { language: "English (US)".into(), identifier: Some("notes&edition".into()), cover: None, + ..Default::default() }, ) .unwrap(); From 2a3d1dc1833d95655024e64fbb1c151ecab940c0 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:48:27 +0200 Subject: [PATCH 074/200] Styles: name the attributes in the params docs, and a wrong one in the command's error (#777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - describe style.paragraph.create (and .edit, style.character.create, .edit) gave chars?: {…} and para?: {…}. The descriptions now say they take type.char's and type.para's attributes and name the common ones. - A wrong attribute gave "invalid parameters for `style`: unknown attribute `font`". The error now names the command and the group, and the attribute meant (nearest name, or one the key starts or ends) or else lists them all: "invalid parameters for `style.paragraph.create`: chars: unknown attribute `font`; did you mean `fontFamily`?". The suggestion comes from set_json, so type.char, type.para and find.change get it too. - chars or para that isn't an object is an error instead of ignored. Fixes #777 --- crates/doc/src/attrs.rs | 70 +++++++++++++++++++++++++++++- crates/engine/src/cmd/style.rs | 78 +++++++++++++++++++++++++++------- 2 files changed, 132 insertions(+), 16 deletions(-) diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index b99740e2c..6dc9b8efd 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -62,7 +62,7 @@ macro_rules! attr_set { return Ok(()); } )* - Err(format!("unknown attribute `{key}`")) + Err(unknown_attribute(key, &[ $( stringify!($field), )* ])) } /// Fields where `self` differs from `base`'s resolved value (used for "clear overrides"). pub fn diff_from(&self, base: &$props) -> $attrs { @@ -104,6 +104,57 @@ fn attr_key(snake: &'static str) -> &'static str { snake } +/// `snake_case` → `camelCase`, as commands name attributes. +fn camel(snake: &str) -> String { + let mut out = String::with_capacity(snake.len()); + let mut up = false; + for c in snake.chars() { + if c == '_' { + up = true; + } else if up { + out.push(c.to_ascii_uppercase()); + up = false; + } else { + out.push(c); + } + } + out +} + +/// Edits (insert, delete, replace) that turn `a` into `b`, ignoring case and underscores. +fn edit_distance(a: &str, b: &str) -> usize { + let norm = |s: &str| s.chars().filter(|c| *c != '_').map(|c| c.to_ascii_lowercase()).collect::>(); + let (a, b) = (norm(a), norm(b)); + let mut prev: Vec = (0..=b.len()).collect(); + for (i, ca) in a.iter().enumerate() { + let mut row = vec![i + 1]; + for (j, cb) in b.iter().enumerate() { + let d = (prev[j] + usize::from(ca != cb)).min(prev[j + 1] + 1).min(row[j] + 1); + row.push(d); + } + prev = row; + } + prev.last().copied().unwrap_or(0) +} + +/// The error for an attribute `key` that isn't one of `fields` (snake_case): the nearest one +/// (a typo, or a name it starts or ends: `font` → `fontFamily`), or else all of them. +fn unknown_attribute(key: &str, fields: &[&str]) -> String { + let k = key.to_ascii_lowercase().replace('_', ""); + let flat = |f: &str| f.replace('_', ""); + let near = fields + .iter() + .map(|f| (edit_distance(key, f), *f)) + .filter(|(d, _)| *d <= (k.len() / 3).max(1)) + .min() + .map(|(_, f)| f) + .or_else(|| fields.iter().copied().find(|f| k.len() >= 3 && (flat(f).starts_with(&k) || flat(f).ends_with(&k)))); + match near { + Some(f) => format!("unknown attribute `{key}`; did you mean `{}`?", camel(f)), + None => format!("unknown attribute `{key}`; the attributes are: {}", fields.iter().map(|f| camel(f)).collect::>().join(", ")), + } +} + // ---------- value types ---------- #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -946,6 +997,23 @@ impl ParaProps { mod tests { use super::*; + /// #777: an unknown attribute names the nearest one, or lists them all. + #[test] + fn unknown_attributes_suggest_the_right_name() { + let mut c = CharAttrs::default(); + assert_eq!(c.set_json("font", &serde_json::json!("X")).unwrap_err(), "unknown attribute `font`; did you mean `fontFamily`?"); + assert_eq!(c.set_json("fontFamly", &serde_json::json!("X")).unwrap_err(), "unknown attribute `fontFamly`; did you mean `fontFamily`?"); + let all = c.set_json("zzzzzz", &serde_json::json!(1)).unwrap_err(); + assert!( + all.starts_with("unknown attribute `zzzzzz`; the attributes are: ") && all.contains("fontFamily") && all.contains("fontStyle"), + "{all}" + ); + assert!(c.set_json("font_family", &serde_json::json!("X")).is_ok() && c.set_json("fontFamily", &serde_json::json!("X")).is_ok()); + let mut p = ParaAttrs::default(); + assert_eq!(p.set_json("spaceBefor", &serde_json::json!(3)).unwrap_err(), "unknown attribute `spaceBefor`; did you mean `spaceBefore`?"); + assert_eq!(camel("first_line_indent"), "firstLineIndent"); + } + #[test] fn a_partial_rule_keeps_the_other_fields() { let current = ParaProps { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f099..3c854ad87 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -86,7 +86,7 @@ pub fn specs() -> Vec { "New Paragraph Style…", [], None, - "{name, basedOn?, nextStyle?, para?: {…}, chars?: {…}, fromSelection?: bool}", + "{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, …}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}", has_doc, create_para ), @@ -95,7 +95,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {… ruleAbove?/ruleBelow?: only the named rule fields change}, chars?: {…}}", + "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", has_doc, edit_para ), @@ -105,7 +105,7 @@ pub fn specs() -> Vec { "New Character Style…", [], None, - "{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)", + "{name, basedOn?: style | \"[None]\", chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)", has_doc, create_char ), @@ -114,7 +114,7 @@ pub fn specs() -> Vec { "Character Style Options…", [], None, - "{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {attr: value | null (no longer set)}} — renaming updates every use", + "{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {character attribute (as type.char names them: fontFamily, fontStyle, size, …): value | null (no longer set)}} — renaming updates every use", has_doc, edit_char ), @@ -380,12 +380,18 @@ pub fn specs() -> Vec { ] } -fn attrs(v: Option<&Value>, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result { +/// The `group` (`chars`, `para`) object of command `cmd`'s params as attributes; a bad attribute +/// is that command's parameter error, naming the group. +fn attrs(cmd: &str, group: &str, p: &Value, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result { let mut a = T::default(); - if let Some(o) = v.and_then(Value::as_object) { - for (k, v) in o { - set(&mut a, k, v).map_err(|e| bad("style", e))?; + match p.get(group) { + None | Some(Value::Null) => {} + Some(Value::Object(o)) => { + for (k, v) in o { + set(&mut a, k, v).map_err(|e| bad(cmd, format!("{group}: {e}")))?; + } } + Some(_) => return Err(bad(cmd, format!("{group} must be an object of attributes"))), } Ok(a) } @@ -436,8 +442,8 @@ fn apply_char(s: &mut Session, p: &Value) -> Result { fn create_para(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); - let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?; - let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let mut para: ParaAttrs = attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json(k, v))?; + let mut chars: CharAttrs = attrs("style.paragraph.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) && let Ok(cur) = s.execute("type.selectionAttrs", &json!({})) && !cur.is_null() @@ -479,7 +485,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { fn edit_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); - let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); @@ -491,7 +497,7 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { } // A rule object changes only the rule fields it names, over the style's resolved rule. let (current, _) = d.styles.resolve_para_style(&name); - let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; + let para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; st.para.merge(¶); st.chars.merge(&chars); @@ -631,7 +637,7 @@ fn delete_para(s: &mut Session, p: &Value) -> Result { fn create_char(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); - let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let mut chars: CharAttrs = attrs("style.character.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) @@ -647,7 +653,7 @@ fn create_char(s: &mut Session, p: &Value) -> Result { // Replay the explicit fields so null clears a captured value rather than looking absent. if let Some(overrides) = p.get("chars").and_then(Value::as_object) { for (key, value) in overrides { - captured.set_json(key, value).map_err(|e| bad("style", e))?; + captured.set_json(key, value).map_err(|e| bad("style.character.create", format!("chars: {e}")))?; } } chars = captured; @@ -671,7 +677,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { if name == designcraft_doc::NO_CHAR_STYLE { return Err(bad("style.character.edit", "[None] can't be edited")); } - let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let chars: CharAttrs = attrs("style.character.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?; // `null` unsets an attribute (the style no longer sets it). let unset: Vec = p @@ -1199,6 +1205,48 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) { } } +/// #777: the params descriptions name the attributes, and a wrong one is the command's error. +#[cfg(test)] +mod attribute_error_tests { + use super::*; + use crate::cmd::find_command; + + #[test] + fn a_wrong_attribute_names_the_command_and_the_right_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let err = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_err().to_string(); + assert_eq!( + err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": {"font": "Source Sans 3"}})), + "invalid parameters for `style.paragraph.create`: chars: unknown attribute `font`; did you mean `fontFamily`?" + ); + assert_eq!( + err(&mut s, "style.paragraph.create", json!({"name": "H", "para": {"spaceBefor": 6}})), + "invalid parameters for `style.paragraph.create`: para: unknown attribute `spaceBefor`; did you mean `spaceBefore`?" + ); + assert!( + err(&mut s, "style.character.create", json!({"name": "C", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.character.create`: chars:") + ); + assert!(err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": "bold"})).contains("chars must be an object of attributes")); + s.execute("style.paragraph.create", &json!({"name": "H", "chars": {"fontFamily": "Source Sans 3", "size": 14}})).unwrap(); + assert!( + err(&mut s, "style.paragraph.edit", json!({"name": "H", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.paragraph.edit`: chars:") + ); + s.execute("style.character.create", &json!({"name": "C", "chars": {"fontStyle": "Bold"}})).unwrap(); + assert!( + err(&mut s, "style.character.edit", json!({"name": "C", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.character.edit`: chars:") + ); + for id in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { + let doc = find_command(id).unwrap().params; + assert!(doc.contains("as type.char names them: fontFamily"), "{id}: {doc}"); + } + assert!(find_command("style.paragraph.create").unwrap().params.contains("as type.para names them: align")); + } +} + #[cfg(test)] mod color_tests { use super::*; From 368958ee2f8509d031767ea05d2ebeccdcb056b2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:51:52 +0000 Subject: [PATCH 075/200] fix(macos): a menu-bar command runs at once instead of on the next input (#784) --- ROADMAP.md | 1 + apps/designcraft/src/main.rs | 2 +- apps/designcraft/src/native_menu.rs | 16 +++++++++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..d49927e83 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** · macOS: a command chosen with the mouse in the menu bar runs at once. It used to wait for the next mouse move or key press over the window, so File › Open, Place and Save As seemed to take 10–20 seconds to open their dialogs. 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/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 26ce2ee4f..705839ae7 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -45,7 +45,7 @@ impl eframe::App for App { #[cfg(target_os = "macos")] { if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() { - self.menu = Some(native_menu::NativeMenu::install(&mut self.app)); + self.menu = Some(native_menu::NativeMenu::install(&mut self.app, ctx)); } if let Some(m) = &mut self.menu { m.poll(&mut self.app, ctx); diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index f51db197a..f6e475259 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::str::FromStr; +use std::sync::mpsc::{Receiver, channel}; use designcraft_ui_egui::DesignApp; use designcraft_ui_egui::menus::{self, Item}; @@ -19,6 +20,7 @@ enum Handle { pub struct NativeMenu { _menu: Menu, items: HashMap, + events: Receiver, last_refresh: f64, } @@ -31,7 +33,15 @@ fn accel(sc: &str) -> Option { } impl NativeMenu { - pub fn install(app: &mut DesignApp) -> Self { + pub fn install(app: &mut DesignApp, ctx: &egui::Context) -> Self { + // A click in the menu bar reaches the app with no input over the window, so nothing would + // draw the frame that runs the command until the pointer or a key comes back: wake egui. + let (tx, events) = channel(); + let repaint = ctx.clone(); + MenuEvent::set_event_handler(Some(move |e: MenuEvent| { + let _ = tx.send(e); + repaint.request_repaint(); + })); let menu = Menu::new(); let mut items = HashMap::new(); let mut counter = 0usize; @@ -72,11 +82,11 @@ impl NativeMenu { (p.is_null() && accel(sc).is_some()).then(|| id.clone()) }) .collect(); - Self { _menu: menu, items, last_refresh: 0.0 } + Self { _menu: menu, items, events, last_refresh: 0.0 } } pub fn poll(&mut self, app: &mut DesignApp, ctx: &egui::Context) { - while let Ok(ev) = MenuEvent::receiver().try_recv() { + while let Ok(ev) = self.events.try_recv() { if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) { menus::activate_native(app, ctx, &cmd.clone(), ¶ms.clone()); } From e08f3c5997fc284b493b650cf702548e580985f2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:51:53 +0000 Subject: [PATCH 076/200] test(paths): path edits on a locked layer change nothing (#735) --- crates/engine/src/cmd/object.rs | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index a03e055ae..ecc6a7586 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3322,6 +3322,50 @@ mod path_edit_tests { assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err()); assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err()); } + + /// Every edit that `path_editable` guards, aimed at the path `id` of `session_with_path`. + fn path_edits(id: u64) -> Vec<(&'static str, serde_json::Value)> { + vec![ + ("path.appendAnchor", json!({"id": id, "anchor": {"p": [400, 250]}})), + ("path.setAnchor", json!({"id": id, "anchor": 0, "p": [90, 110]})), + ("path.close", json!({"id": id})), + ("path.moveAnchors", json!({"id": id, "anchors": [[0, 0]], "dx": 10, "dy": 10})), + ("path.reverse", json!({"ids": [id]})), + ("path.addAnchor", json!({"id": id, "at": [150, 100]})), + ("path.deleteAnchor", json!({"id": id, "subpath": 0, "anchor": 0})), + ("path.convertAnchor", json!({"id": id, "subpath": 0, "anchor": 0})), + ("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]})), + ] + } + + fn assert_no_path_edit(s: &mut crate::Session, id: u64, why: &str) { + let before = anchors(s, id); + for (cmd, p) in path_edits(id) { + assert!(s.execute(cmd, &p).is_err(), "{cmd} {why}"); + } + assert_eq!(anchors(s, id), before, "{why}"); + assert!(!s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].closed, "{why}"); + } + + #[test] + fn path_edits_leave_a_path_on_a_locked_layer_alone() { + let (mut s, id) = session_with_path(); + let layer = s.doc().unwrap().doc.item(ItemId(id)).unwrap().layer; + s.execute("layer.set", &json!({"id": layer.0, "locked": true})).unwrap(); + assert_no_path_edit(&mut s, id, "on a locked layer"); + } + + #[test] + fn path_edits_leave_a_path_in_a_locked_group_alone() { + let (mut s, id) = session_with_path(); + let other = s.execute("frame.create", &json!({"rect": [400, 400, 450, 450]})).unwrap()["id"].as_u64().unwrap(); + let group = s.execute("object.group", &json!({"ids": [id, other]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.lock", &json!({"ids": [group]})).unwrap(); + assert!(!s.doc().unwrap().doc.item(ItemId(id)).unwrap().locked, "only the group is locked"); + assert_no_path_edit(&mut s, id, "in a locked group"); + } } #[cfg(test)] From 2be4588658b72df187ee88ba31fb033d362e1e8b Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:51:54 +0000 Subject: [PATCH 077/200] test(properties): the Stroke link test starts with Properties active (#736) --- crates/ui-egui/src/panel_docking_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 65879b1a0..be36fa920 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -307,6 +307,7 @@ fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { app.run("file.new", json!({"width":480,"height":360})).unwrap(); app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap(); app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.run("window.panel", json!({"panel":"properties"})).unwrap(); assert!(!app.ui.docking.as_ref().unwrap().contains(&"stroke".into())); let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( |ui, app: &mut DesignApp| { From b0c364ee6001cba6e36e7bce572d7d861a402131 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:56:17 +0200 Subject: [PATCH 078/200] EPUB: one content file per chapter, split before each h1 (#768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole book was one content.xhtml, which is slow on e-readers and loses chapter starts. The body is now split before each h1 into content.xhtml, content2.xhtml, …: a section a split goes through is closed and reopened, and a story that opens with its h1 starts in the new file with its id. The manifest and spine list the files in order, and the navigation links each heading to the file it is in. HTML export stays one page. Fixes #768 --- crates/epub/src/lib.rs | 167 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 152 insertions(+), 15 deletions(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 57e7c9d88..3f6dd820d 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -464,6 +464,9 @@ fn story_html_in(doc: &Document, sid: StoryId, ids: Option<&str>) -> String { Some(prefix) if heading_level(tag).is_some() => format!(" id=\"{prefix}-p{pi}\""), _ => String::new(), }; + if ids.is_some() && heading_level(tag) == Some(1) { + out.push_str(CHAPTER_MARK); + } let _ = write!(out, "<{tag}{id} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); if text_empty { out.push_str(" "); @@ -510,6 +513,50 @@ fn story_html_in(doc: &Document, sid: StoryId, ids: Option<&str>) -> String { out } +/// Where the EPUB body splits into chapter files: before each `h1`. +const CHAPTER_MARK: &str = ""; + +/// The body split at its [`CHAPTER_MARK`]s into chapter bodies, each with its sections closed +/// and the one a split goes through reopened in the next; a part with nothing but the start of a +/// section (a story that opens with its `h1`) moves that start into the next part. +fn split_chapters(body: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut carry: Option = None; + for piece in body.split(CHAPTER_MARK) { + let mut part = carry.take().unwrap_or_default(); + part.push_str(piece); + if let Some(open) = part.rfind("")) { + let tag_end = part[open..].find('>').map_or(part.len(), |e| open + e + 1); + if part[tag_end..].trim().is_empty() { + carry = Some(part[open..].to_string()); + part.truncate(open); + } else { + part.push_str("
    \n"); + carry = Some("
    \n".to_string()); + } + } + if !part.trim().is_empty() { + out.push(part); + } + } + if let Some(rest) = carry.filter(|c| !c.trim().is_empty()) + && let Some(last) = out.last_mut() + { + // Only an opening tag can be left over; close it where it stands. + last.push_str(&rest); + last.push_str("
    \n"); + } + if out.is_empty() { + out.push(String::new()); + } + 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) } +} + /// The level of a heading element (`h1` → 1 … `h6` → 6). fn heading_level(tag: &str) -> Option { tag.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| (1..=6).contains(n)) @@ -544,11 +591,12 @@ fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, String, u8)> { /// The table of contents as nested `li`/`ol` items (the content of the navigation's outer /// `ol`): a deeper level goes into the previous item, skipped levels count as one. -fn nav_list(entries: &[(String, String, u8)]) -> String { +fn nav_list(entries: &[(String, String, u8)], files: &BTreeMap) -> String { let mut out = String::new(); let mut open: Vec = Vec::new(); for (target, label, level) in entries { - let item = format!("
  • {}", xml_text(label)); + let file = files.get(target).map_or("content.xhtml", String::as_str); + let item = format!("
  • {}", xml_text(label)); match open.last().copied() { None => open.push(*level), Some(top) if *level > top => { @@ -776,11 +824,26 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string(), true); let lang = language_tag(&opts.language); - let chapter = format!( - "\n\n\n{}\n\n{body}\n\n", - xml_text(&title) - ); - let mut nav_items = nav_list(&toc); + // One file per chapter (split before each h1), so e-readers needn't load the whole book. + let chapters: Vec = split_chapters(&body) + .into_iter() + .map(|part| { + format!( + "\n\n\n{}\n\n{part}\n\n", + xml_text(&title) + ) + }) + .collect(); + // The file each navigation target (an element id) ended up in. + let mut files: BTreeMap = BTreeMap::new(); + for (k, c) in chapters.iter().enumerate() { + for rest in c.split(" id=\"").skip(1) { + if let Some(id) = rest.split('"').next() { + files.entry(id.to_string()).or_insert_with(|| chapter_file(k)); + } + } + } + let mut nav_items = nav_list(&toc, &files); if nav_items.is_empty() { nav_items = format!("
  • {}
  • ", xml_text(&title)); } @@ -790,8 +853,14 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr xml_text(opts.toc_title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(contents_title(&opts.language))) ); let mut manifest = String::from( - "\n\n\n", + "\n\n", ); + let mut spine = String::new(); + for k in 0..chapters.len() { + let item = if k == 0 { "content".to_string() } else { format!("content{}", k + 1) }; + let _ = writeln!(manifest, "", chapter_file(k)); + let _ = writeln!(spine, ""); + } for (k, (name, (mime, _))) in images.iter().enumerate() { let _ = writeln!(manifest, "", xml_attr(name), xml_attr(mime)); } @@ -802,7 +871,7 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); let opf = format!( - "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\n\n\n\n{manifest}\n\n{cover_spine}\n\n\n", + "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\n\n\n\n{manifest}\n\n{cover_spine}{spine}\n\n", xml_text(&id), xml_text(&title) ); @@ -819,11 +888,14 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr ("OEBPS/content.opf", opf.as_bytes()), ("OEBPS/nav.xhtml", nav.as_bytes()), ("OEBPS/style.css", stylesheet(doc).as_bytes()), - ("OEBPS/content.xhtml", chapter.as_bytes()), ] { z.start_file(name, deflate).map_err(zerr)?; z.write_all(data).map_err(io)?; } + for (k, c) in chapters.iter().enumerate() { + z.start_file(format!("OEBPS/{}", chapter_file(k)), deflate).map_err(zerr)?; + z.write_all(c.as_bytes()).map_err(io)?; + } for (name, (_, data)) in &images { z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?; z.write_all(data).map_err(io)?; @@ -907,12 +979,13 @@ mod tests { }; let nl = EpubOptions { language: "nl".into(), ..Default::default() }; let nav = read(&d, &nl, "OEBPS/nav.xhtml"); - let link = |t: &str, l: &str| format!("{l}"); + let link = |f: &str, t: &str, l: &str| format!("{l}"); + // Each h1 starts a chapter file (#768). let want = format!( "

    Inhoud

    1. {}
      1. {}
    2. {}
    ", - link("s0-p0", "One"), - link("s0-p2", "One point one"), - link("s0-p4", "Two") + link("content.xhtml", "s0-p0", "One"), + link("content.xhtml", "s0-p2", "One point one"), + link("content2.xhtml", "s0-p4", "Two") ); assert!(nav.contains(&want), "{nav}"); assert!(!nav.contains("Text after the figure"), "{nav}"); @@ -925,7 +998,71 @@ mod tests { let lid = plain.default_layer(); 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}"); + assert!(nav.contains(&format!("

    Contents

    1. {}
    ", link("content.xhtml", "s0", "Opening"))), "{nav}"); + } + + /// #768: the book is split into one file per chapter, before each h1, with well-formed + /// sections; the spine and manifest list the files in order. + #[test] + fn chapters_are_split_at_h1() { + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + let lid = d.default_layer(); + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: "Chapter".into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut().export_tags.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new() }); + // A title page without a heading, then a story that opens with a chapter and has a + // second one in the middle. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap(); + let (_, sid) = d + .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 200.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default()) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].style = "Chapter".into(); + st.paras[2].style = "Chapter".into(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml")); + assert!(c1.contains("Title page") && !c1.contains("One"), "{c1}"); + assert!(c2.contains("
    \n

    \n

    ").count(), "{c}"); + assert!(!c.contains(CHAPTER_MARK)); + } + let opf = read("OEBPS/content.opf"); + assert!(opf.contains("\n\n\n"), "{opf}"); + assert!(opf.contains("\n

    a

    \n

    \n"), vec!["
    \n

    a

    \n
    \n"]); + assert_eq!( + split_chapters(&format!("
    \n{m}

    A

    \n
    \n")), + vec!["
    \n

    A

    \n
    \n"] + ); + assert_eq!( + split_chapters(&format!("
    \n

    a

    \n{m}

    B

    \n
    \n")), + vec!["
    \n

    a

    \n
    \n", "
    \n

    B

    \n
    \n"] + ); + assert_eq!(split_chapters(""), vec![String::new()]); } #[test] 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 079/200] 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 080/200] 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 081/200] #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 ac8f44d749fb7a2553e9c1e6e43a885da7a4c827 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:17:58 +0200 Subject: [PATCH 082/200] Images: reject EPS bounding boxes with nonfinite corners or size (#740) --- crates/images/src/eps.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/images/src/eps.rs b/crates/images/src/eps.rs index 01d959292..48b42d3a6 100644 --- a/crates/images/src/eps.rs +++ b/crates/images/src/eps.rs @@ -29,7 +29,10 @@ pub fn bounding_box(bytes: &[u8]) -> Option<[f64; 4]> { let read = |key: &str| { head.lines().find_map(|l| { let v: Vec = l.strip_prefix(key)?.split_whitespace().filter_map(|t| t.parse().ok()).collect(); - (v.len() == 4 && v[2] > v[0] && v[3] > v[1]).then(|| [v[0], v[1], v[2], v[3]]) + // Finite corners and a finite size (subtracting extreme corners can overflow to infinity). + let [x0, y0, x1, y1] = <[f64; 4]>::try_from(v).ok()?; + let finite = [x0, y0, x1, y1, x1 - x0, y1 - y0].iter().all(|c| c.is_finite()); + (finite && x1 > x0 && y1 > y0).then_some([x0, y0, x1, y1]) }) }; read("%%HiResBoundingBox:").or_else(|| read("%%BoundingBox:")) @@ -100,4 +103,16 @@ mod tests { assert_eq!(crate::pixel_size(&p), Some((10, 5))); assert!(!is_eps(b"%!PS-Adobe-3.0\nnot encapsulated")); } + + #[test] + fn eps_bounding_box_rejects_nonfinite_sizes() { + let bb = |line: &str| bounding_box(format!("%!PS-Adobe-3.0 EPSF-3.0\n{line}\n").as_bytes()); + assert_eq!(bb("%%BoundingBox: 0 0 100 50"), Some([0.0, 0.0, 100.0, 50.0])); + assert_eq!(bb("%%BoundingBox: 0 0 inf 50"), None); + assert_eq!(bb("%%BoundingBox: -inf 0 100 50"), None); + assert_eq!(bb("%%BoundingBox: -1e308 0 1e308 50"), None); + assert_eq!(bb("%%BoundingBox: 100 50 0 0"), None); + // A bad high-resolution box falls back to the plain one. + assert_eq!(bb("%%HiResBoundingBox: 0 0 inf inf\n%%BoundingBox: 0 0 10 20"), Some([0.0, 0.0, 10.0, 20.0])); + } } From 0820ef857c94e3259a4104d0d14fcaed2463167d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:17:59 +0200 Subject: [PATCH 083/200] MCP: render_page and screenshot are not read-only because they can write to path (#733) --- crates/mcp/src/tests.rs | 10 ++++++++++ crates/mcp/src/tools.rs | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 3051a5ad4..0f3cdd56c 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -167,6 +167,16 @@ fn tool_definitions_are_well_formed() { } } +#[test] +fn tools_that_write_files_are_not_read_only() { + // `render_page` and `screenshot` write a file when given `path`, so they must not advertise `readOnlyHint: true`. + for name in ["render_page", "screenshot", "export_png"] { + let definition = tool_definitions().into_iter().find(|v| v["name"] == name).unwrap(); + assert!(definition["inputSchema"]["properties"].get("path").is_some(), "{name} takes a path"); + assert_eq!(definition["annotations"]["readOnlyHint"], false, "{name} writes files"); + } +} + // ---------- layout through the tools ---------- #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c51bac8ed..88c3a6097 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -232,7 +232,7 @@ pub fn tool_definitions() -> Vec { }), &[], ), - true, + false, ), tool( "export_png", @@ -249,7 +249,7 @@ pub fn tool_definitions() -> Vec { "Screenshot", &app_only("Capture the whole app window (menus, panels, canvas) and return it as a PNG image. Optionally also keep it at `path`."), obj(json!({"path": string("Also write the PNG here")}), &[]), - true, + false, ), // ----- input ----- tool( 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 084/200] 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 b8e7e8ede681db7abfd90d4f9ba7f93d42394744 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:19:22 +0200 Subject: [PATCH 085/200] fix(render): cap the failed-image cache at 64 entries (#728) --- crates/render/src/images.rs | 42 ++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 117a0fa11..465867e82 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -3,7 +3,7 @@ //! //! Entries are keyed by the address of the asset's encoded bytes (`Arc>`) and hold a //! clone of that `Arc`, so the address can't be reused while cached. Least recently used levels -//! are evicted beyond a memory budget. +//! are evicted beyond a memory budget; at most `MAX_BAD` failed assets are remembered. use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -15,6 +15,9 @@ use crate::{decode_pixmap_page, halve}; /// Decoded pixels kept (bytes). const BUDGET: usize = 768 << 20; +/// Failed assets remembered; the oldest is forgotten (and retried if drawn again) beyond this. +const MAX_BAD: usize = 64; + struct Entry { _bytes: Arc>, pm: Arc, @@ -28,7 +31,20 @@ struct Cache { bytes: usize, clock: u64, /// Assets that failed to decode (don't retry every frame). - bad: HashMap<(usize, u32), Arc>>, + /// Value: (insertion stamp, the encoded bytes keeping the address reserved). + bad: HashMap<(usize, u32), (u64, Arc>)>, +} + +impl Cache { + /// Remember a failed asset, evicting the oldest failures beyond [`MAX_BAD`]. + fn mark_bad(&mut self, key: (usize, u32), bytes: &Arc>) { + self.clock += 1; + self.bad.insert(key, (self.clock, bytes.clone())); + while self.bad.len() > MAX_BAD { + let Some(oldest) = self.bad.iter().min_by_key(|(_, (stamp, _))| *stamp).map(|(k, _)| *k) else { break }; + self.bad.remove(&oldest); + } + } } static CACHE: Mutex> = Mutex::new(None); @@ -97,7 +113,7 @@ pub fn decoded(bytes: &Arc>, page: u32) -> Option> { Some(pm) } None => { - with(|c| c.bad.insert((id, page), bytes.clone())); + with(|c| c.mark_bad((id, page), bytes)); None } } @@ -133,3 +149,23 @@ pub fn clear() { c.bytes = 0; }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_assets_are_capped_and_oldest_is_dropped() { + let mut c = Cache::default(); + let buffers: Vec>> = (0..MAX_BAD + 10).map(|i| Arc::new(vec![i as u8; 4])).collect(); + for b in &buffers { + c.mark_bad((Arc::as_ptr(b) as usize, 0), b); + } + assert_eq!(c.bad.len(), MAX_BAD); + let first = &buffers[0]; + assert!(!c.bad.contains_key(&(Arc::as_ptr(first) as usize, 0))); + assert_eq!(Arc::strong_count(first), 1, "evicted buffer must be released"); + let last = &buffers[MAX_BAD + 9]; + assert!(c.bad.contains_key(&(Arc::as_ptr(last) as usize, 0))); + } +} 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 086/200] 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 087/200] 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 ec4ff485df8e87f21d766d1d010321e8bd5a4881 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:43:16 +0200 Subject: [PATCH 088/200] Keep a missing graphic missing when it is pasted into another document (#720) --- crates/engine/src/cmd/links.rs | 42 +++++++++++++++++++++++++++++++++ crates/engine/src/cmd/object.rs | 25 +++++++++++++------- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 73de3ea7e..933fdfbd4 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -427,4 +427,46 @@ mod shared_asset_tests { assert_eq!((g.asset, g.crop), (same, [1.0; 4])); assert_eq!(dst.assets.len(), 2); } + + #[test] + fn pasted_missing_graphic_does_not_show_an_unrelated_target_asset() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Asset, Graphic, Item, ItemId, Shape, SpreadRef}; + let mut src = Document::new(&NewDocument::default()); + let mut dst = Document::new(&NewDocument::default()); + // The source graphic points at an asset the source does not have; the target uses that id for another image. + let missing = AssetId(src.alloc()); + dst.assets.insert( + missing, + Arc::new(Asset { + id: missing, + name: "p.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(png(8, 4)), + pixels: None, + page: 0, + }), + ); + let iid = ItemId(src.alloc()); + let mut it = Item::new( + iid, + src.default_layer(), + Shape::Rectangle, + designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), + ); + it.content = Content::Graphic(Graphic { + asset: missing, + size: (4.0, 4.0), + xf: designcraft_geom::Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [1.0; 4], + }); + src.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap(); + let g = dst.item(ids[0]).and_then(|i| i.graphic()).unwrap(); + assert_ne!(g.asset, missing); + assert!(!dst.assets.contains_key(&g.asset)); + } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc01..1d796cb07 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use designcraft_color::BlendMode; use designcraft_doc::{ - Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, TextSel, + AssetId, Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, + TextSel, }; use designcraft_geom::{Affine, Point, Rect, Vec2, shapes}; use serde_json::{Value, json}; @@ -966,13 +967,24 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp Ok(out) } +/// The id to reference in `dst` for asset `id` of `src`. When `src` lacks the asset (a missing graphic), an id that `dst` +/// already uses for a different asset is swapped for a fresh unused one so the reference stays missing instead of showing +/// an unrelated image. +fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId { + match dst.adopt_asset(src, id) { + Some(a) => a, + None if dst.assets.contains_key(&id) => AssetId(dst.alloc()), + None => id, + } +} + fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap) { it.id = ItemId(dst.alloc()); let my_id = it.id; if let Some(m) = it.media.as_mut() && let Some(p) = m.poster { - m.poster = dst.adopt_asset(src, p).or(Some(p)); + m.poster = Some(adopt_or_dangle(dst, src, p)); } match &mut it.content { Content::Text(tf) => { @@ -984,9 +996,8 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has st.id = n; let mut assets = HashMap::new(); for a in designcraft_doc::story_asset_refs(&st) { - if let Some(na) = dst.adopt_asset(src, a) - && na != a - { + let na = adopt_or_dangle(dst, src, a); + if na != a { assets.insert(a, na); } } @@ -1003,9 +1014,7 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has } } Content::Graphic(g) => { - if let Some(a) = dst.adopt_asset(src, g.asset) { - g.asset = a; - } + g.asset = adopt_or_dangle(dst, src, g.asset); } Content::Group { items } => { for c in items.iter_mut() { From 79b9971939deae7ae03305053cc008ba49f53372 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:43:15 +0200 Subject: [PATCH 089/200] fix(pdf): leave form fields and media out of PDF/A-2b exports (#743) --- crates/pdf/src/export.rs | 6 +++++- crates/pdf/src/tests.rs | 26 ++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..ceceefeff 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -215,12 +215,16 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) // group that got past it, with its page. crate::pdfx::cmyk_group_spaces(&mut bytes); } - // Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X. + // Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X + // or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and + // Screen annotations aren't allowed in PDF/A. let fields = crate::forms::collect(doc, &sheets); let media = if opts.media { crate::forms::collect_media(doc, &sheets) } else { Vec::new() }; if !fields.is_empty() || !media.is_empty() { if opts.standard == Standard::PdfX4 { warnings.push("PDF/X-4: form fields and media were left out".into()); + } else if opts.standard == Standard::PdfA2b { + warnings.push("PDF/A-2b: form fields and media were left out".into()); } else { match crate::forms::add_fields(&bytes, &fields, &media) { Some(b) => bytes = b, diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d057..c58c7533b 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -402,6 +402,32 @@ fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec) { (rec.0, rec.1) } +#[test] +fn pdfa_leaves_out_form_fields_added_after_validation() { + let mut d = doc_with_text("Archive me"); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut b = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(36.0, 400.0, 200.0, 430.0))); + b.form_field = Some(designcraft_doc::FormField { + kind: designcraft_doc::FieldKind::TextField, + name: "name".into(), + value: String::new(), + options: Vec::new(), + multiline: false, + required: false, + font_size: 0.0, + }); + d.insert_item(SpreadRef::Doc(0), b, None).unwrap(); + let plain = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(String::from_utf8_lossy(&plain).contains("/AcroForm"), "control: a plain export has the field"); + let opts = PdfOptions { standard: Standard::PdfA2b, created: Some(1_700_000_000), ..Default::default() }; + let a = export_pdf_with_report(&d, &Cache::new(), &opts).unwrap(); + let text = String::from_utf8_lossy(&a.bytes).to_string(); + assert!(text.contains("pdfaid")); + assert!(!text.contains("/AcroForm") && !text.contains("NeedAppearances"), "PDF/A must not get the form update"); + assert!(a.warnings.iter().any(|w| w.contains("PDF/A-2b") && w.contains("left out")), "{:?}", a.warnings); +} + #[test] fn missing_glyphs_print_as_boxes() { // Source Serif 4 lacks 語: its .notdef box is drawn as a path, not as a .notdef glyph diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..cbdc17974 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -357,7 +357,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). - Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text. - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. -- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X). +- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X or PDF/A). - 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. From 3c070a8cdd3be018cc19fb07423c9adb357aa6e1 Mon Sep 17 00:00:00 2001 From: Saad Abdullah Date: Tue, 13 Oct 2026 16:49:51 +0000 Subject: [PATCH 090/200] fix(geom): decompose of a flattened transform reports 0 shear instead of NaN --- crates/geom/src/decompose.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/decompose.rs b/crates/geom/src/decompose.rs index 4c06a4261..0ea247366 100644 --- a/crates/geom/src/decompose.rs +++ b/crates/geom/src/decompose.rs @@ -24,7 +24,13 @@ pub fn decompose(m: Affine) -> Decomposed { let det = a * d - b * c; let rotation = (-b).atan2(a).to_degrees(); let rotation = if rotation <= -180.0 + 1e-9 { 180.0 } else { rotation }; - Decomposed { scale_x: sx, scale_y: det / sx, rotation, shear: ((a * c + b * d) / det).atan().to_degrees() } + // A flattened transform (e.g. scale_y = 0, so `det` and the shear numerator are both 0) has + // no recoverable shear: 0 / 0 would be NaN, and a NaN here poisons the object when the + // caller feeds the decomposed values back into `compose` (transform.set does exactly that). + // Report 0 instead; a non-zero numerator over a zero determinant still limits to ±90°. + let shear = ((a * c + b * d) / det).atan().to_degrees(); + let shear = if shear.is_nan() { 0.0 } else { shear }; + Decomposed { scale_x: sx, scale_y: det / sx, rotation, shear } } /// The linear transform with these values (no translation); `decompose` inverts it. @@ -50,4 +56,29 @@ mod tests { // transform.rotate's convention: rotate(-θ) is θ counter-clockwise. assert!((decompose(Affine::rotate(-0.5)).rotation - 0.5f64.to_degrees()).abs() < 1e-9); } + + #[test] + fn flattened_transform_has_no_nan_shear() { + // Regression: an object scaled to zero height (scale_y = 0) is singular — its + // determinant and shear numerator are both 0, and decompose used to report NaN shear, + // which compose then turned into a NaN transform. Shear is unrecoverable there, so + // it reports 0 and every other field stays finite and exact. + for v in [ + Decomposed { scale_x: 1.0, scale_y: 0.0, rotation: 0.0, shear: 20.0 }, + Decomposed { scale_x: 2.0, scale_y: 0.0, rotation: 30.0, shear: -45.0 }, + ] { + let back = decompose(compose(&v)); + assert!(back.scale_x.is_finite() && back.scale_y.is_finite() && back.rotation.is_finite() && back.shear.is_finite(), "{v:?} → {back:?}"); + assert_eq!(back.shear, 0.0, "{v:?} → {back:?}"); + assert!((back.scale_x - v.scale_x).abs() < 1e-9, "{v:?} → {back:?}"); + assert_eq!(back.scale_y, 0.0, "{v:?} → {back:?}"); + assert!((back.rotation - v.rotation).abs() < 1e-9, "{v:?} → {back:?}"); + // Feeding the decomposed values back into compose stays finite too. + assert!(compose(&back).as_coeffs().iter().all(|c| c.is_finite()), "{v:?} → {back:?}"); + } + // Directly singular matrices, including one whose shear numerator is non-zero + // (that case limits to ±90°, not NaN). + assert_eq!(decompose(Affine::new([1.0, 0.0, 0.0, 0.0, 0.0, 0.0])).shear, 0.0); + assert!((decompose(Affine::new([1.0, 0.0, 1.0, 0.0, 0.0, 0.0])).shear.abs() - 90.0).abs() < 1e-9); + } } From 370dca2ca24fce5545a87280cfa0b7ef2277b742 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 19:11:58 +0200 Subject: [PATCH 091/200] feat(render): gradient strokes, on screen, in PDF and in the Gradient panel A gradient swatch on a stroke drew nothing: strokes were painted with a single resolved colour. LYH Studio episode 6 gives the loop on the right page (9:13) and, at first by accident, the circle (1:19, "right now it is on the stroke") a gradient stroke. - doc: Stroke::gradient_angle / gradient_vector (as Fill's) and Stroke::as_fill(), the stroke's paint as a fill. - render and PDF: a gradient stroke is painted like a gradient fill, across the path's box (dash gaps and arrowheads keep their paints). - engine: object.gradient {target: stroke} edits the stroke's gradient (kind, stops, angle, the Gradient Swatch tool's from/to); scaling moves the stroke's vector with the shape. - IDML: GradientStrokeStart/Length/Angle round-trip. - Gradient panel: acts on the stroke when the toolbox's Stroke box is in front, as InDesign's follows the Fill/Stroke proxy. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 20 ++++++++ crates/engine/src/cmd/object.rs | 11 +++-- crates/engine/src/cmd/style.rs | 71 ++++++++++++++++++++++++--- crates/idml/src/export.rs | 8 +++ crates/idml/src/import.rs | 13 +++++ crates/idml/src/tests.rs | 17 +++++++ crates/pdf/src/export.rs | 17 +++++-- crates/render/src/lib.rs | 17 +++++-- 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/swatches.rs | 30 +++++++---- docs/target-app-parity.md | 2 +- 17 files changed, 192 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..ac9e48805 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** · Gradient swatches work on strokes: drawn across the path's box on screen and in PDF (they drew nothing), set with `object.gradient {target: stroke}` and in the Gradient panel when the Stroke box is in front; the stroke's gradient vector moves with the shape and round-trips through IDML. 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/doc/src/item.rs b/crates/doc/src/item.rs index 0a701998e..fe4ff27ef 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -224,6 +224,12 @@ pub struct Stroke { /// Overprint Gap (Attributes panel). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub gap_overprint: bool, + /// A gradient swatch's angle on the stroke, as [`Fill::gradient_angle`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_angle: Option, + /// The Gradient Swatch tool's vector on the stroke, as [`Fill::gradient_vector`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_vector: Option<[f64; 4]>, } impl Default for Stroke { @@ -244,11 +250,25 @@ impl Default for Stroke { gap_tint: 1.0, overprint: false, gap_overprint: false, + gradient_angle: None, + gradient_vector: None, } } } impl Stroke { + /// The stroke's paint (swatch, tint and gradient geometry) as a fill, to paint it the same way: + /// a gradient swatch on a stroke runs across the object's box as on its fill. + pub fn as_fill(&self) -> Fill { + Fill { + swatch: self.swatch.clone(), + tint: self.tint, + gradient_angle: self.gradient_angle, + gradient_vector: self.gradient_vector, + overprint: self.overprint, + } + } + /// How far the painted stroke can reach beyond the path (arrowheads reach further). pub fn extent(&self) -> f64 { if self.start == Arrowhead::None && self.end == Arrowhead::None { self.weight } else { self.weight.max(0.5) * 5.5 } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc01..50b4d577c 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1103,12 +1103,15 @@ 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) } -/// Transform an item's geometry in its own space: the path and the gradient vector with it. +/// Transform an item's geometry in its own space: the path and the gradient vectors (fill and +/// stroke) with it. pub(crate) fn bake(it: &mut Item, inner: Affine) { it.path.transform(inner); - if let Some([x0, y0, x1, y1]) = it.fill.gradient_vector { - let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1)); - it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]); + for v in [&mut it.fill.gradient_vector, &mut it.stroke.gradient_vector] { + if let Some([x0, y0, x1, y1]) = *v { + let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1)); + *v = Some([a.x, a.y, b.x, b.y]); + } } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f099..8562ca74d 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -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}|[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), target?: fill|stroke, ids?} → {swatch, kind, stops} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch)", super::has_selection, apply_gradient ), @@ -922,13 +922,18 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result { }; let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let angle = p.get("angle").and_then(Value::as_f64); + let stroke = match str_param(p, "target") { + None | Some("fill") => false, + Some("stroke") => true, + Some(t) => return Err(bad("object.gradient", format!("unknown target {t}"))), + }; let (from, to) = (super::point_param(p, "from"), super::point_param(p, "to")); let ids = super::targets(s, p)?; s.edit(|d, _| { let mut out = Value::Null; for id in &ids { let Some(it) = d.item(*id) else { continue }; - let cur_name = it.fill.swatch.clone(); + let cur_name = if stroke { it.stroke.swatch.clone() } else { it.fill.swatch.clone() }; let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned(); let mut g = cur.clone().unwrap_or_default(); if let Some(k) = kind { @@ -960,16 +965,22 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result { }; let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse()); let Some(it) = d.item_mut(*id) else { continue }; - it.fill.swatch = name.clone(); - it.fill.tint = 1.0; + // The fill's or the stroke's paint: swatch, tint, angle, vector. + let (swatch, tint, g_angle, g_vector) = if stroke { + (&mut it.stroke.swatch, &mut it.stroke.tint, &mut it.stroke.gradient_angle, &mut it.stroke.gradient_vector) + } else { + (&mut it.fill.swatch, &mut it.fill.tint, &mut it.fill.gradient_angle, &mut it.fill.gradient_vector) + }; + *swatch = name.clone(); + *tint = 1.0; if let Some(a) = angle { - it.fill.gradient_angle = Some(a); - it.fill.gradient_vector = None; + *g_angle = Some(a); + *g_vector = None; } if let (Some(a), Some(b), Some(inv)) = (from, to, inv) { let (a, b) = (inv * a, inv * b); if (b - a).hypot() > 1e-6 { - it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]); + *g_vector = Some([a.x, a.y, b.x, b.y]); } } if out.is_null() { @@ -1751,3 +1762,49 @@ mod sample_color_tests { } } } + +#[cfg(test)] +mod gradient_stroke_tests { + use serde_json::json; + + /// Grey along a 20 pt line from (20, 60) to (180, 60): near its start, middle and end. + fn along(img: &designcraft_render::Rendered) -> (u8, u8, u8) { + (img.pixel(26, 60)[0], img.pixel(100, 60)[0], img.pixel(174, 60)[0]) + } + + #[test] + fn a_gradient_on_a_stroke_runs_across_the_path_on_screen_and_in_pdf() { + 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 r = s + .execute( + "object.gradient", + &json!({"ids": [id], "target": "stroke", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap()); + assert_eq!(it.fill.swatch, designcraft_color::swatch::NONE, "the fill is left alone"); + let mut rr = designcraft_render::Renderer::new(); + let (a, m, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a < 40 && (90..170).contains(&m) && b > 215, "screen: {a} {m} {b}"); + // The PDF, placed back and drawn: the same. + 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": "stroke.pdf", "x": 0, "y": 0, "width": 200})) + .unwrap(); + let pd = p.doc().unwrap().doc.clone(); + let (a, m, b) = along(&rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a < 40 && (90..170).contains(&m) && b > 215, "pdf: {a} {m} {b}"); + // The Gradient Swatch tool's drag on the stroke: reversed by dragging right to left. + s.execute("object.gradient", &json!({"ids": [id], "target": "stroke", "from": [180, 60], "to": [20, 60]})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let (a, _, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a > 215 && b < 40, "dragged: {a} {b}"); + assert!(s.execute("object.gradient", &json!({"ids": [id], "target": "gap"})).is_err()); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index d01abe9e7..cfdbae52d 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1673,6 +1673,14 @@ impl<'a> Ex<'a> { } else if let Some(a) = it.fill.gradient_angle { el.set("GradientFillAngle", num(a)); } + // The same on the stroke. + if let Some([x0, y0, x1, y1]) = it.stroke.gradient_vector { + el.set("GradientStrokeStart", format!("{} {}", num(x0), num(y0))); + el.set("GradientStrokeLength", num((x1 - x0).hypot(y1 - y0))); + el.set("GradientStrokeAngle", num((-(y1 - y0)).atan2(x1 - x0).to_degrees())); + } else if let Some(a) = it.stroke.gradient_angle { + el.set("GradientStrokeAngle", num(a)); + } self.stroke_attrs(&mut el, &it.stroke); // Corner options: map path-order corners to the named corners by geometry. if !it.corners.is_none() { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be7..c0959d7c9 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2129,6 +2129,19 @@ impl<'r> Importer<'r> { it.stroke.swatch = swatch::NONE.into(); it.stroke.weight = 1.0; } + // A gradient on the stroke: its angle, or the Gradient Swatch tool's vector. + if let Some(a) = e.num("GradientStrokeAngle") + && a != 0.0 + { + it.stroke.gradient_angle = Some(a); + } + let start: Option> = e.prop("GradientStrokeStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect()); + if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength")) + && len > 0.0 + { + let a = e.num("GradientStrokeAngle").unwrap_or(0.0).to_radians(); + it.stroke.gradient_vector = Some([x, y, x + len * a.cos(), y - len * a.sin()]); + } } // Corners. let cn = crate::export::corner_names(&it.path); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index a2ee8f2d5..8eacd1142 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1479,3 +1479,20 @@ fn type_on_a_path_keeps_its_start_and_end_brackets() { assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket"); } } + +#[test] +fn a_strokes_gradient_vector_round_trips() { + let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = designcraft_doc::Item::new( + id, + d.default_layer(), + designcraft_doc::Shape::Path, + designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(10.0, 10.0, 110.0, 60.0)), + ); + it.stroke.gradient_vector = Some([10.0, 35.0, 110.0, 35.0]); + d.insert_item(designcraft_doc::SpreadRef::Doc(0), it, None).unwrap(); + let back = crate::import_idml(&crate::export_idml(&d)).unwrap(); + let v = back.spreads[0].items[0].stroke.gradient_vector.unwrap(); + assert!(v.iter().zip([10.0, 35.0, 110.0, 35.0]).all(|(a, b)| (a - b).abs() < 1e-6), "{v:?}"); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..64434732a 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1062,7 +1062,14 @@ impl Exporter<'_> { fn stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: &Path) { let st = &it.stroke; - let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return }; + // A gradient swatch runs across the path's box, as on a fill. + let paint: krilla::paint::Paint = if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, &st.swatch).is_some() { + let Some(p) = self.fill_paint(&st.as_fill(), bp.bounding_box()) else { return }; + p + } else { + let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return }; + c.into() + }; let closed = it.path.is_closed(); let mut cap = match st.cap { designcraft_doc::Cap::Butt => krilla::paint::LineCap::Butt, @@ -1083,7 +1090,7 @@ impl Exporter<'_> { s.draw_path(path); s.set_stroke(None); } - s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); + s.set_fill(Some(Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); s.draw_path(&op); s.set_fill(None); return; @@ -1135,7 +1142,7 @@ impl Exporter<'_> { s.draw_path(path); } s.set_stroke(Some(Stroke { - paint: c.clone().into(), + paint: paint.clone(), width: width as f32, miter_limit: st.miter_limit.max(1.0) as f32, line_cap: cap, @@ -1156,12 +1163,12 @@ impl Exporter<'_> { let Some(p) = to_path(&h.path) else { continue }; match h.outline { Some(w) => { - s.set_stroke(Some(Stroke { paint: c.clone().into(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() })); + s.set_stroke(Some(Stroke { paint: paint.clone(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() })); s.draw_path(&p); s.set_stroke(None); } None => { - s.set_fill(Some(krilla::paint::Fill { paint: c.clone().into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); + s.set_fill(Some(krilla::paint::Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); s.draw_path(&p); s.set_fill(None); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..c8b8d642a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -621,8 +621,17 @@ impl Renderer { } fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) { + self.draw_stroke_painted(ctx, f, it, bp, xf); + ctx.reset_paint_transform(); + } + + /// The stroke, in its colour or gradient (a gradient runs across the path's box, as a fill's). + fn draw_stroke_painted(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) { let st = &it.stroke; - let Some(c) = f.doc.resolve_color(&st.swatch, st.tint) else { return }; + let (paint, bounds) = (st.as_fill(), bp.bounding_box()); + if f.doc.resolve_color(&st.swatch, st.tint).is_none() && designcraft_color::swatch::resolve_gradient(&f.doc.swatches, &st.swatch).is_none() { + return; + } let mut stroke = kurbo::Stroke::new(st.weight) .with_caps(match st.cap { designcraft_doc::Cap::Butt => kurbo::Cap::Butt, @@ -642,7 +651,9 @@ impl Renderer { _ => {} } ctx.set_transform(f.view * xf); - ctx.set_paint(color_of(&c, 1.0)); + if !set_fill_paint(ctx, f.doc, &paint, bounds) { + return; + } let closed = it.path.is_closed(); // Arrowheads: the path is shortened under them; they're drawn after it. let arrows = designcraft_doc::arrow::apply(bp, st, closed); @@ -669,7 +680,7 @@ impl Renderer { if op { ctx.pop_layer(); } - ctx.set_paint(color_of(&c, 1.0)); + set_fill_paint(ctx, f.doc, &paint, bounds); } // Stripes, wavy and hash strokes are fills (along the path's centre line). if let Some(o) = kind.outline(bp, st.weight, 0.05 * f.px) { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..dd81bca66 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,16 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ( + "The stroke isn't a gradient.", + [ + "Die Kontur ist kein Verlauf.", + "Le contour n’est pas un dégradé.", + "El trazo no es un degradado.", + "線はグラデーションではありません。", + "描边不是渐变。", + ], + ), ("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 a182d09ec..4b195386e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("The stroke isn't a gradient.", "الحد ليس تدرجًا."), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f6..d9ea6eb63 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("The stroke isn't a gradient.", "La traccia non è una sfumatura."), ("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 642b4a4c0..988b0ccd0 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", "ペースト"), + ("The stroke isn't a gradient.", "線はグラデーションではありません。"), ("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 982dfb20d..f592575ef 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("The stroke isn't a gradient.", "O traçado não é um gradiente."), ("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 f3bc8fb35..5d0d613bc 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("The stroke isn't a gradient.", "Обведення не є градієнтом."), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 7544347a9..06b035dc2 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -407,11 +407,21 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { )); return; }; - let fill = it.fill.clone(); + // The fill's gradient, or the stroke's when the toolbox's Stroke box is in front (as InDesign's + // Gradient panel follows the Fill/Stroke proxy). + let target = if app.ui.stroke_front { "stroke" } else { "fill" }; + let fill = if app.ui.stroke_front { it.stroke.as_fill() } else { it.fill.clone() }; let Some(g) = designcraft_color::swatch::resolve_gradient(&st.doc.swatches, &fill.swatch).cloned() else { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "The fill isn't a gradient.")).color(t.text_dim)); + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr( + &app.ui.language, + if app.ui.stroke_front { "The stroke isn't a gradient." } else { "The fill isn't a gradient." }, + )) + .color(t.text_dim), + ); if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Gradient"))).clicked() { - let _ = app.run("object.gradient", json!({})); + let _ = app.run("object.gradient", json!({"target": target, "target": target})); } return; }; @@ -424,7 +434,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { .show_ui(ui, |ui| { for (k, v) in [("Linear", "linear"), ("Radial", "radial")] { if ui.selectable_label(g.kind.label() == k, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() { - let _ = app.run("object.gradient", json!({"kind": v})); + let _ = app.run("object.gradient", json!({"target": target, "kind": v})); } } }); @@ -435,7 +445,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }) .clicked() { - let _ = app.run("object.gradient", json!({"reverse": true})); + let _ = app.run("object.gradient", json!({"target": target, "reverse": true})); } }); let sel_id = egui::Id::new("grad_sel"); @@ -447,13 +457,13 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some(v) = crate::widgets::number(ui, "grad_loc", loc, " %", 54.0, 1) { let mut stops = stops_json(&g); stops[sel]["location"] = json!(v.clamp(0.0, 100.0)); - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); } if !radial { crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Angle:")); let angle = fill.gradient_vector.map(|[x0, y0, x1, y1]| (-(y1 - y0)).atan2(x1 - x0).to_degrees()).or(fill.gradient_angle).unwrap_or(0.0); if let Some(v) = crate::widgets::number(ui, "grad_angle", Some(angle), "°", 54.0, 1) { - let _ = app.run("object.gradient", json!({"angle": v})); + let _ = app.run("object.gradient", json!({"target": target, "angle": v})); } } }); @@ -503,7 +513,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } else { stops[i]["location"] = json!(o as f64 * 100.0); } - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); } if resp.clicked() && let Some(p) = resp.interact_pointer_pos() @@ -516,7 +526,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let (c, a) = g.sample(o); let mut stops = stops_json(&g); stops.push(json!({"location": o as f64 * 100.0, "color": c.to_hex(), "opacity": a as f64 * 100.0, "midpoint": 50.0})); - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); sel = g.stops.iter().filter(|s| s.offset <= o).count(); } None => {} @@ -565,7 +575,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() { let mut stops = stops_json(&g); stops[sel]["color"] = json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])); - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); } }); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..b9f1e6e6e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -421,7 +421,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. - Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. -- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. +- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). The Gradient panel follows the Fill/Stroke proxy. - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. - Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. From ac54c8faf7a6e85d6ca5572e8e2177f210d7bca8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 19:30:55 +0200 Subject: [PATCH 092/200] feat(text): gradient swatches on text, laid across the frame as in InDesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LYH Studio episode 6 (8:28) gives the "1969" headline the "New Gradient Swatch" (magenta → cyan) from the Swatches panel. InDesign runs a gradient on text across its text frame's box. DesignCraft resolved a character fill to one solid colour, so a gradient swatch drew no text at all. - render: a character fill or stroke that names a gradient swatch is painted with that gradient over the frame's box in text space (type on a path: the path's box). - pdf: text runs take a gradient paint: the shading is laid out in the frame's text space and mapped into each run's origin, so skew and horizontal scale don't bend it; outlined runs and type on a path too. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 55 +++++++++++++++++++++++++ crates/pdf/src/export.rs | 26 +++++++++--- crates/pdf/src/text.rs | 50 +++++++++++++++++------ crates/render/src/text.rs | 76 ++++++++++++++++++++++++++++------- docs/target-app-parity.md | 2 +- 6 files changed, 177 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..9689ce7f2 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** · Text takes gradient swatches: a gradient fill or stroke on characters spans the text frame's box (on a path: the path's), on screen and in PDF. 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/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e88a..3441b451c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2048,3 +2048,58 @@ mod multi_click_tests { assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); } } + +#[cfg(test)] +mod gradient_text_tests { + use serde_json::json; + + /// Mean red and blue of the ink (pixels whose green is low: red → blue has none) in columns + /// `x0..x1`. + fn 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 { + (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); + } + } + } + assert!(n > 50.0, "no ink in {x0}..{x1}"); + (r / n, b / n) + } + + /// Red ink at the frame's left, blue at its right. + fn check(img: &designcraft_render::Rendered, what: &str) { + let (lr, lb) = ink(img, 0, 60); + let (rr, rb) = ink(img, 200, 300); + assert!(lr > lb + 80.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); + assert!(rb > rr + 80.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); + } + + #[test] + fn a_gradient_on_text_spans_its_frame_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); + // 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 id = designcraft_doc::ItemId(shape.as_u64().unwrap()); + let swatch = s.doc().unwrap().doc.item(id).unwrap().fill.swatch.clone(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": "IIIIIIIIIIIIIIIIIIIIIIIIIIIIII"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 30})).unwrap(); + s.execute("type.char", &json!({"size": 60, "tracking": 0, "fill": swatch})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + check(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(), "screen"); + // The PDF, placed back and drawn: the same gradient. + let pdf = designcraft_pdf::export_pdf(&d, &s.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": "text.pdf", "x": 0, "y": 0, "width": 300})) + .unwrap(); + let d = p.doc().unwrap().doc.clone(); + check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf"); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..45eb331ff 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -161,6 +161,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, + text_area: None, para_tags: HashMap::new(), }; if ex.rgb_only { @@ -338,6 +339,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, + text_area: None, para_tags: HashMap::new(), }; let pairs = booklet_pairs(n, opts.kind); @@ -468,6 +470,9 @@ pub(crate) struct Exporter<'a> { /// While drawing a story's frame for the structure tree: the story (text runs are tagged /// per paragraph, everything else in the frame is an artifact). pub(crate) tag_story: Option, + /// While drawing a frame's text: the frame's box in text space, which a gradient on the text + /// spans (as InDesign lays it across the frame). + pub(crate) text_area: Option, /// Marked content of each (story, paragraph index). pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec>, } @@ -925,8 +930,10 @@ impl Exporter<'_> { // Type on a path: the glyphs as outlines along the path. for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) { let st = &cs.styles[si as usize]; - if let (Some(c), Some(p)) = (self.swatch_color(&st.fill, st.fill_tint), to_path(&gp)) { - s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); + // A gradient spans the path's box. + let fill = designcraft_doc::Fill { swatch: st.fill.clone(), tint: st.fill_tint, ..designcraft_doc::Fill::none() }; + if let (Some(paint), Some(p)) = (self.fill_paint(&fill, bp.bounding_box()), to_path(&gp)) { + s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); s.draw_path(&p); s.set_fill(None); } @@ -937,7 +944,9 @@ impl Exporter<'_> { if local != Affine::IDENTITY { s.push_transform(&tf(local)); } + self.text_area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds())); self.frame_text(s, &cs, ft, text); + self.text_area = None; if local != Affine::IDENTITY { s.pop(); } @@ -977,7 +986,13 @@ 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 { + pub(crate) fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { + self.fill_paint_in(fill, bounds, Affine::IDENTITY) + } + + /// [`Self::fill_paint`] for a gradient laid out in a space that `xf` maps to the current one + /// (text runs: the frame's text space under the run's origin). + pub(crate) fn fill_paint_in(&mut self, fill: &designcraft_doc::Fill, bounds: Rect, xf: Affine) -> Option { 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; @@ -1028,7 +1043,7 @@ impl Exporter<'_> { cx, cy, cr: r, - transform: Transform::identity(), + transform: tf(xf), spread_method: SpreadMethod::Pad, stops, anti_alias: false, @@ -1048,7 +1063,7 @@ 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, @@ -1577,6 +1592,7 @@ mod image_content_cache_tests { tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, + text_area: None, para_tags: HashMap::new(), } } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index bf59efded..b7921f326 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -15,13 +15,31 @@ use designcraft_fonts::{FontDb, FontFace}; use designcraft_geom::kurbo::Shape as _; use designcraft_geom::{Affine, BezPath, Rect}; use krilla::geom::Point; -use krilla::paint::Stroke; +use krilla::num::NormalizedF32; +use krilla::paint::{Fill, FillRule, Paint, Stroke}; use krilla::surface::Surface; use krilla::tagging::{Artifact, ArtifactType, ContentTag, SpanTag}; use krilla::text::{Font, GlyphId, KrillaGlyph}; use crate::export::{Exporter, solid_fill, tf, to_path}; +impl Exporter<'_> { + /// A text run's paint: solid, or a gradient spanning the frame's box (`area`, text space), + /// for glyphs drawn under `origin` (text space ← run space). + fn text_paint(&mut self, swatch: &str, tint: f32, origin: Affine, area: Rect) -> Option { + if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch).is_some() && origin.determinant().abs() > 1e-12 { + let fill = designcraft_doc::Fill { swatch: swatch.into(), tint, ..designcraft_doc::Fill::none() }; + return self.fill_paint_in(&fill, self.text_area.unwrap_or(area), origin.inverse()); + } + self.swatch_color(swatch, tint).map(Into::into) + } +} + +/// An opaque nonzero fill with `paint`. +fn paint_fill(paint: Paint) -> Fill { + Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero } +} + /// Private-use characters DesignCraft stores for markers (page numbers, section markers, breaks). fn is_marker(c: char) -> bool { ('\u{E000}'..='\u{E1FF}').contains(&c) @@ -289,17 +307,23 @@ impl Exporter<'_> { fn run(&mut self, s: &mut Surface, cs: &ComposedStory, glyphs: &[PlacedGlyph], baseline: f64, story: &str) { let g0 = &glyphs[0]; let st = &cs.styles[g0.style as usize]; - let fill = self.swatch_color(&st.fill, st.fill_tint); - let stroke = - if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 { self.swatch_color(&st.stroke, st.stroke_tint) } else { None }; - if fill.is_none() && stroke.is_none() { - return; - } let face = g0.face; let size = g0.sy * face.upem; let hs = g0.sx / g0.sy; let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0); + // Outside a frame (no text area), a gradient spans the run. + let end = glyphs.last().map_or(g0.x, |g| g.x + g.adv); + let run_area = Rect::new(g0.x, baseline - size, end.max(g0.x + 1.0), baseline + size * 0.25); + let fill = self.text_paint(&st.fill, st.fill_tint, origin, run_area); + let stroke = if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 { + self.text_paint(&st.stroke, st.stroke_tint, origin, run_area) + } else { + None + }; + if fill.is_none() && stroke.is_none() { + return; + } // A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text. if g0.gid == 0 { self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight); @@ -322,8 +346,8 @@ impl Exporter<'_> { }) .collect(); s.push_transform(&tf(origin)); - s.set_fill(fill.map(|c| solid_fill(c, 1.0))); - s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() })); + s.set_fill(fill.map(paint_fill)); + s.set_stroke(stroke.map(|paint| Stroke { paint, width: st.stroke_weight as f32, ..Default::default() })); s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false); s.set_fill(None); s.set_stroke(None); @@ -338,8 +362,8 @@ impl Exporter<'_> { glyphs: &[PlacedGlyph], origin: Affine, size: f64, - fill: Option, - stroke: Option, + fill: Option, + stroke: Option, stroke_weight: f64, ) { let db = FontDb::global(); @@ -354,8 +378,8 @@ impl Exporter<'_> { } let Some(p) = to_path(&bp) else { return }; s.push_transform(&tf(origin)); - s.set_fill(fill.map(|c| solid_fill(c, 1.0))); - s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() })); + s.set_fill(fill.map(paint_fill)); + s.set_stroke(stroke.map(|paint| Stroke { paint, width: stroke_weight as f32, ..Default::default() })); s.draw_path(&p); s.set_fill(None); s.set_stroke(None); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index cc46ad729..f10c0d348 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -19,6 +19,45 @@ use vello_cpu::peniko; use crate::{Frame, Renderer, color_of, rect_overlaps}; +/// A text colour: solid, or a gradient swatch laid across the frame (InDesign spans a gradient on +/// text across its frame's box). +#[derive(Clone)] +enum TextPaint { + Solid(peniko::Color), + Gradient(designcraft_doc::Fill), +} + +impl TextPaint { + fn of(doc: &designcraft_doc::Document, swatch: &str, tint: f32) -> Option { + if designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch).is_some() { + return Some(TextPaint::Gradient(designcraft_doc::Fill { swatch: swatch.into(), tint, ..designcraft_doc::Fill::none() })); + } + doc.resolve_color(swatch, tint).map(|c| TextPaint::Solid(color_of(&c, 1.0))) + } + + /// Make this the context's paint; a gradient runs across `area` (text space). + fn set(&self, ctx: &mut RenderContext, doc: &designcraft_doc::Document, area: Rect) { + match self { + TextPaint::Solid(c) => ctx.set_paint(*c), + TextPaint::Gradient(fill) => { + crate::set_fill_paint(ctx, doc, fill, area); + } + } + } +} + +/// The box a gradient on a frame's text spans: the frame's, in text space (the union of its lines +/// when the frame is gone). +fn text_area(doc: &designcraft_doc::Document, ft: &FrameText) -> Rect { + if let Some(it) = doc.item(ft.frame) { + let local = doc.text_local(it); + if local.determinant().abs() > 1e-12 { + return local.inverse().transform_rect_bbox(it.inner_bounds()); + } + } + ft.lines.iter().filter_map(line_box).reduce(|a, b| a.union(b)).unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)) +} + /// Cached outlines of one composed line. pub(crate) struct LineGlyphs { /// Frame-space bounds of everything drawn for the line (empty lines: `None`). @@ -349,13 +388,16 @@ impl Renderer { ctx.set_transform(m); if !shown.is_empty() { let fg = self.glyphs.get(cs, ft); - // Resolve each style's paints once per frame. - let mut fills: Vec>> = vec![None; cs.styles.len()]; - let mut fill_of = |si: u32| -> Option { - *fills[si as usize].get_or_insert_with(|| { - let st = &cs.styles[si as usize]; - doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0)) - }) + // Resolve each style's paints once per frame; gradients span the frame. + let area = text_area(doc, ft); + let mut fills: Vec>> = vec![None; cs.styles.len()]; + let mut fill_of = |si: u32| -> Option { + fills[si as usize] + .get_or_insert_with(|| { + let st = &cs.styles[si as usize]; + TextPaint::of(doc, &st.fill, st.fill_tint) + }) + .clone() }; shown.retain(|&i| fg.lines[i].bounds.is_some_and(|b| rect_overlaps(b, vis))); // Paint all underlines before any glyphs: skewed runs and tightly spaced lines @@ -384,19 +426,21 @@ impl Renderer { None, ); } - ctx.set_paint(c); + c.set(ctx, doc, area); ctx.fill_path(bp); + ctx.reset_paint_transform(); if op { ctx.pop_layer(); } } let st = &cs.styles[*si as usize]; if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) + && let Some(c) = TextPaint::of(doc, &st.stroke, st.stroke_tint) { - ctx.set_paint(color_of(&c, 1.0)); + c.set(ctx, doc, area); ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); ctx.stroke_path(bp); + ctx.reset_paint_transform(); } } } @@ -496,18 +540,22 @@ impl Renderer { ) { let doc = f.doc; ctx.set_transform(f.view * xf); + // A gradient on type on a path spans the path's box. + let area = path.bounding_box(); for (si, bp) in designcraft_compose::path_glyphs(cs, ft, path, pt) { let st = &cs.styles[si as usize]; - if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) { - ctx.set_paint(color_of(&c, 1.0)); + if let Some(c) = TextPaint::of(doc, &st.fill, st.fill_tint) { + c.set(ctx, doc, area); ctx.fill_path(&bp); + ctx.reset_paint_transform(); } if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) + && let Some(c) = TextPaint::of(doc, &st.stroke, st.stroke_tint) { - ctx.set_paint(color_of(&c, 1.0)); + c.set(ctx, doc, area); ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); ctx.stroke_path(&bp); + ctx.reset_paint_transform(); } self.stats.glyphs += 1; } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..eae72856a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -421,7 +421,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. - Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. -- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. +- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF). - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. - Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. From 7e344e85f17737c02f7f4e5e57180ebca80d6df8 Mon Sep 17 00:00:00 2001 From: Govinda Roy Date: Wed, 14 Oct 2026 00:16:42 +0600 Subject: [PATCH 093/200] Compose: spaces at the end of a paragraph take no room on the line The paragraph composer counted a trailing space (or several) before the paragraph end or a forced line break in the line's width, so "2 VOOR " in a frame exactly as wide as "2 VOOR" broke before "VOOR" and the frame went overset. InDesign lets those spaces hang past the edge. They now add no width; the line's visible range already left them out. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/breaker.rs | 9 +++++++++ crates/compose/src/tests.rs | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 754cfc3d0..2aa9b3ae7 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -303,8 +303,17 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S let n = glyphs.len(); let mut hy_cache = Vec::new(); let mut last_space = NONE; + // Spaces before the paragraph end or a forced break hang: they take no room on the line (as + // in InDesign, where "2 VOOR " fits a frame as wide as "2 VOOR"). + let mut hangs = vec![false; n]; + for i in (0..n).rev() { + hangs[i] = glyphs[i].is_space() && glyphs.get(i + 1).is_none_or(|nx| hangs[i + 1] || is_forced(nx.ch)); + } for (i, g) in glyphs.iter().enumerate() { let prev_hang = if i > 0 { right_hang(&glyphs[i - 1], sp) } else { 0.0 }; + if hangs[i] { + continue; + } if is_forced(g.ch) { it.push(Item::glue(0.0, INF, 0.0)); ig.push(i); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7a0e790d5..fd7484fce 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2929,3 +2929,18 @@ fn empty_paragraphs_get_no_bullet_or_number() { assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); } } + +#[test] +fn spaces_at_the_end_of_a_paragraph_take_no_room() { + // A frame as wide as "2 VOOR" also holds "2 VOOR " on one line: the trailing space hangs. + let (d, sid, _) = doc_with("2 VOOR", Rect::new(0.0, 0.0, 500.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = all_lines(&cs)[0]; + let width = l.end_x - l.x0; + for text in ["2 VOOR ", "2 VOOR ", "2 VOOR \u{2028}more"] { + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width + 0.02, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let first = all_lines(&cs)[0]; + assert_eq!(&text[first.range.clone()].trim_end(), &"2 VOOR", "{text:?}"); + } +} From 0a34316b82ea3f66b624eaa8a11648ec3d2c70c7 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 20:16:51 +0200 Subject: [PATCH 094/200] feat(ui): Window menu in InDesign's order; Window > Workspace with Essentials Classic LYH Studio episode 6 starts with "Window > Workspace > Essential Classic" and opens panels with "Window > Color > Gradient & Swatches". DesignCraft's Window menu had no Workspace or Color submenu (panels sat in one "Panels" list) and no Essentials Classic workspace. - Window menu: Arrange, Workspace, then the panels alphabetically with InDesign's groups (Color: Color, Gradient, Swatches; Editorial; Interactive; Object & Layout; Output; Styles; Type & Tables; Utilities). Every panel stays reachable; Next/Previous Document moved into Arrange. - Window > Workspace: the built-in workspaces as "[Essentials]", the current one checked, Reset , New Workspace. The app bar's switcher uses the same list and order. - Essentials Classic: the Control panel, no Properties stack, and an icon column of named panel groups ([Pages, Layers, Links] [Stroke, Color] [Swatches] [Effects] [Paragraph] [Text Wrap] [Character Styles, Paragraph Styles] [Gradient]); Window > Pages opens Pages from its group. Saved workspaces keep the groups. - Flyouts and floating panels name Pages, Layers and Properties. - Translations for Editorial, Object & Layout, Styles and Essentials Classic. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/chrome.rs | 14 +- crates/ui-egui/src/dock.rs | 85 ++++++++++--- crates/ui-egui/src/i18n.rs | 13 +- crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ja.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 4 + crates/ui-egui/src/lib.rs | 8 ++ crates/ui-egui/src/menus.rs | 212 +++++++++++++++++++++++++------ docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 13 files changed, 284 insertions(+), 73 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..495aadc61 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 Window menu follows InDesign's order (Arrange, Workspace, then the panels alphabetically with Color, Editorial, Object & Layout, Styles… submenus), and Window › Workspace lists the workspaces as InDesign does, now with Essentials Classic: the Control panel and an icon column of named panel groups. 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/chrome.rs b/crates/ui-egui/src/chrome.rs index c2f1cb48e..5e02276a7 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -63,19 +63,7 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| { let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); - for w in [ - "Essentials", - "Advanced", - "Book", - "Digital Publishing", - "Interactive for PDF", - "Printing and Proofing", - "Typography", - ] - .into_iter() - .map(str::to_string) - .chain(customs.iter().cloned()) - { + for w in crate::menus::WORKSPACES.iter().map(|w| w.to_string()).chain(customs.iter().cloned()) { if ui .selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))) .clicked() diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0d581e992..46cb606e4 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -113,30 +113,77 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }, ); } - // Collapsed icon strip (37 pt) to the left of the panel stack. - egui::Panel::right("icon_column").exact_size(37.0).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { + // Collapsed icon strip (37 pt) to the left of the panel stack; a workspace's iconic groups + // (Essentials Classic) show their names. + let groups = app.ui.icon_groups.clone(); + egui::Panel::right("icon_column").exact_size(icon_column_width(app)).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { dock_header(ui, &t, "«"); ui.add_space(4.0); - ui.vertical_centered(|ui| { - for (id, label, icon) in ICON_PANELS { - let open = app.ui.open_panel.as_deref() == Some(*id); - let floats = app.ui.floating.iter().any(|(p, _)| p == id); - let response = - icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag()); - if response.clicked() { - if floats { - ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", *id)))); - } else { - app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + match &groups { + Some(groups) => { + for (i, group) in groups.iter().enumerate() { + if i > 0 { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); + ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border)); + } + for (id, label, icon) in group.iter().filter_map(|id| panel_entry(id)) { + let on = app.ui.open_panel.as_deref() == Some(id) || app.ui.floating.iter().any(|(p, _)| p == id); + let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag()); + if on { + ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.tool_active); + } else if response.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.hover); + } + icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(8.0, 4.0), vec2(18.0, 18.0)), icon, t.icon); + crate::rtl::paint( + ui.painter(), + r.left_center() + vec2(32.0, 0.0), + egui::Align2::LEFT_CENTER, + crate::i18n::tr(&app.ui.language, label), + semibold(11.5), + t.text, + ); + icon_clicked(app, ui, id, &response); } } - crate::panel_docking::legacy_tab(app, ui, id, &response); - ui.add_space(1.0); } - }); + None => { + ui.vertical_centered(|ui| { + for (id, label, icon) in ICON_PANELS { + let on = app.ui.open_panel.as_deref() == Some(*id) || app.ui.floating.iter().any(|(p, _)| p == id); + let response = icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag()); + icon_clicked(app, ui, id, &response); + ui.add_space(1.0); + } + }); + } + } }); } +/// Width of the icon column: icons only, or icons with names for a workspace's groups. +fn icon_column_width(app: &DesignApp) -> f32 { + if app.ui.icon_groups.is_some() { 150.0 } else { 37.0 } +} + +/// (id, label, icon) of any dock panel. +fn panel_entry(id: &str) -> Option<(&'static str, &'static str, &'static str)> { + DOCK_TABS.iter().chain(ICON_PANELS).find(|p| p.0 == id).copied() +} + +/// A click on a panel's icon: raise it when it floats, else open or close its flyout. +fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egui::Response) { + if response.clicked() { + if app.ui.floating.iter().any(|(p, _)| p == id) { + ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", id)))); + } else { + let open = app.ui.open_panel.as_deref() == Some(id); + app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + } + } + crate::panel_docking::legacy_tab(app, ui, id, response); +} + /// 11 pt dock header strip with a collapse chevron. fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::hover()); @@ -152,10 +199,10 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { } let Some(id) = app.ui.open_panel.clone() else { return }; let t = Tokens::get(ctx); - let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned(); + let label = crate::i18n::tr(&app.ui.language, panel_entry(&id).map(|p| p.1).unwrap_or("Panel")).to_owned(); let screen = ctx.content_rect(); let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::new("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 }; - let pos = egui::pos2(screen.max.x - 37.0 - dock_w - 262.0, 120.0); + let pos = egui::pos2(screen.max.x - icon_column_width(app) - dock_w - 262.0, 120.0); let mut open = true; egui::Area::new(egui::Id::new("panel_flyout")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(0)).show(ui, |ui| { @@ -262,7 +309,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { } let t = Tokens::get(ctx); for (id, at) in app.ui.floating.clone() { - let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned(); + let label = crate::i18n::tr(&app.ui.language, panel_entry(&id).map(|p| p.1).unwrap_or("Panel")).to_owned(); let mut open = true; let mut dock = false; let r = egui::Window::new(label.clone()) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..ffa50c108 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]), + ("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]), + ("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]), + ("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]), ("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", "消去", "清除"]), @@ -2921,7 +2925,14 @@ pub fn style_name<'a>(lang: &str, name: &'a str) -> &'a str { /// Built-in workspace captions; custom workspace names remain unchanged. pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str { match name { - "Essentials" | "Advanced" | "Book" | "Digital Publishing" | "Interactive for PDF" | "Printing and Proofing" | "Typography" => tr(lang, name), + "Essentials" + | "Essentials Classic" + | "Advanced" + | "Book" + | "Digital Publishing" + | "Interactive for PDF" + | "Printing and Proofing" + | "Typography" => tr(lang, name), _ => name, } } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..f17794248 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Editorial", "التحرير"), + ("Object & Layout", "الكائن والتخطيط"), + ("Styles", "الأنماط"), + ("Essentials Classic", "الأساسيات الكلاسيكية"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f6..e389a0390 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Editorial", "Editoriale"), + ("Object & Layout", "Oggetto e layout"), + ("Styles", "Stili"), + ("Essentials Classic", "Essenziali classico"), ("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 642b4a4c0..68a093c4d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Editorial", "エディトリアル"), + ("Object & Layout", "オブジェクト&レイアウト"), + ("Styles", "スタイル"), + ("Essentials Classic", "基本(クラシック)"), ("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 982dfb20d..5474ceb19 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Editorial", "Editorial"), + ("Object & Layout", "Objeto e layout"), + ("Styles", "Estilos"), + ("Essentials Classic", "Essencial clássico"), ("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 f3bc8fb35..91feec029 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Editorial", "Редакція"), + ("Object & Layout", "Об’єкт і макет"), + ("Styles", "Стилі"), + ("Essentials Classic", "Основне (класичне)"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..46863f007 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -104,6 +104,9 @@ pub struct SavedWorkspace { pub docking: Option>, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub docking_hidden: std::collections::BTreeMap>, + /// The workspace's own icon column (see [`UiState::icon_groups`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_groups: Option>>, } /// A missing on-by-default bool stays on. `bool::default` is false. @@ -239,6 +242,10 @@ pub struct UiState { #[serde(skip)] pub docking_opened: Option, pub dock_expanded: bool, + /// A workspace's iconic panel groups (Essentials Classic), shown with their names in the icon + /// column instead of every panel; `None` is the full icon column. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_groups: Option>>, pub units: Unit, pub workspace: String, /// Window › Workspace › New Workspace: saved panel arrangements. @@ -341,6 +348,7 @@ impl Default for UiState { docking_raise: None, docking_opened: None, dock_expanded: true, + icon_groups: None, units: Unit::Picas, workspace: "Essentials".into(), custom_workspaces: Vec::new(), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..b87848930 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -190,7 +190,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "window.workspace", "Workspace", None, - "{name: Essentials|Advanced|Book|Digital Publishing|Interactive for PDF|Printing and Proofing|Typography}", + "{name: Advanced|Book|Digital Publishing|Essentials|Essentials Classic|Interactive for PDF|Printing and Proofing|Typography or a saved workspace}", ), ("window.newWorkspace", "New Workspace…", None, "{name} — saves the current bars and panel arrangement"), ("window.deleteWorkspace", "Delete Workspace…", None, "{name}"), @@ -682,50 +682,77 @@ pub const MENUS: &[(&str, &[&str])] = &[ ), ( "Window", + // InDesign's order: Arrange, Workspace, then the panels alphabetically with their groups. &[ ">Arrange", "ui:window.newWindow", "ui:window.split", + "-", + "ui:window.nextDocument", + "ui:window.previousDocument", + "<", + "ui:window.workspace", + "-", + "ui:window.panel|Articles|{\"panel\": \"articles\"}", + ">Color", + "ui:window.panel|Color|{\"panel\": \"color\"}", + "ui:window.panel|Gradient|{\"panel\": \"gradient\"}", + "ui:window.panel|Swatches|{\"panel\": \"swatches\"}", + "<", + "ui:window.taskBar", + "ui:window.controlBar", + ">Editorial", + "ui:window.panel|Notes|{\"panel\": \"notes\"}", + "ui:window.panel|Track Changes|{\"panel\": \"trackChanges\"}", + "<", + "ui:window.panel|Effects|{\"panel\": \"effects\"}", + "ui:window.panel|Info|{\"panel\": \"info\"}", + ">Interactive", + "ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}", + "ui:window.panel|Buttons and Forms|{\"panel\": \"buttons\"}", + "ui:window.panel|Hyperlinks|{\"panel\": \"hyperlinks\"}", + "ui:window.panel|Liquid Layout|{\"panel\": \"liquid\"}", + "ui:window.panel|Media|{\"panel\": \"media\"}", + "ui:window.panel|Object States|{\"panel\": \"states\"}", + "ui:window.panel|Page Transitions|{\"panel\": \"transitions\"}", + "<", + "ui:window.panel|Layers|{\"panel\": \"layers\"}", + "ui:window.panel|Links|{\"panel\": \"links\"}", + ">Object & Layout", + "ui:window.panel|Align|{\"panel\": \"align\"}", + "ui:window.panel|Pathfinder|{\"panel\": \"pathfinder\"}", + "<", + ">Output", + "ui:window.panel|Attributes|{\"panel\": \"attributes\"}", + "ui:window.panel|Preflight|{\"panel\": \"preflight\"}", + "<", + "ui:window.panel|Pages|{\"panel\": \"pages\"}", + "ui:window.panel|Properties|{\"panel\": \"properties\"}", + "ui:window.panel|Stroke|{\"panel\": \"stroke\"}", + ">Styles", + "ui:window.panel|Character Styles|{\"panel\": \"characterStyles\"}", + "ui:window.panel|Paragraph Styles|{\"panel\": \"paragraphStyles\"}", + "<", + "ui:window.panel|Text Wrap|{\"panel\": \"textWrap\"}", + ">Type & Tables", + "ui:window.panel|Character|{\"panel\": \"character\"}", + "ui:window.panel|Conditional Text|{\"panel\": \"conditions\"}", + "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", + "ui:window.panel|Paragraph|{\"panel\": \"paragraph\"}", + "ui:window.panel|Table|{\"panel\": \"table\"}", + "<", + ">Utilities", + "ui:window.panel|Book|{\"panel\": \"book\"}", + "ui:window.panel|Data Merge|{\"panel\": \"dataMerge\"}", + "ui:window.panel|Library|{\"panel\": \"library\"}", + "ui:window.panel|Scripts|{\"panel\": \"scripts\"}", + "ui:window.panel|Tags|{\"panel\": \"tags\"}", "<", "-", "ui:window.hidePanels", "ui:window.hidePanelsExceptTools", - "ui:window.nextDocument", - "ui:window.previousDocument", - "-", - "ui:window.controlBar", - "ui:window.taskBar", "ui:window.toolsDoubleColumn", "-", - "ui:window.panel", - "-", - ">Type & Tables", - "ui:window.panel|Conditional Text|{\"panel\": \"conditions\"}", - "ui:window.panel|Notes|{\"panel\": \"notes\"}", - "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", - "<", - ">Interactive", - "ui:window.panel|Hyperlinks|{\"panel\": \"hyperlinks\"}", - "ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}", - "ui:window.panel|Articles|{\"panel\": \"articles\"}", - "ui:window.panel|Object States|{\"panel\": \"states\"}", - "ui:window.panel|Buttons and Forms|{\"panel\": \"buttons\"}", - "ui:window.panel|Media|{\"panel\": \"media\"}", - "ui:window.panel|Page Transitions|{\"panel\": \"transitions\"}", - "ui:window.panel|Track Changes|{\"panel\": \"trackChanges\"}", - "ui:window.panel|Scripts|{\"panel\": \"scripts\"}", - "ui:window.panel|Liquid Layout|{\"panel\": \"liquid\"}", - "ui:window.panel|Tags|{\"panel\": \"tags\"}", - "ui:window.panel|Book|{\"panel\": \"book\"}", - "<", - ">Utilities", - "ui:window.panel|Data Merge|{\"panel\":\"dataMerge\"}", - "<", - ">Output", - "ui:window.panel|Attributes|{\"panel\": \"attributes\"}", - "cmd:preflight.run", - "<", - "-", "ui:window.brightness", ], ), @@ -746,6 +773,22 @@ pub const MENUS: &[(&str, &[&str])] = &[ ), ]; +/// The built-in workspaces, in InDesign's order. +pub const WORKSPACES: &[&str] = + &["Advanced", "Book", "Digital Publishing", "Essentials", "Essentials Classic", "Interactive for PDF", "Printing and Proofing", "Typography"]; + +/// Essentials Classic's icon column: InDesign's iconic panel groups, top to bottom. +pub const CLASSIC_GROUPS: &[&[&str]] = &[ + &["pages", "layers", "links"], + &["stroke", "color"], + &["swatches"], + &["effects"], + &["paragraph"], + &["textWrap"], + &["characterStyles", "paragraphStyles"], + &["gradient"], +]; + pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| (c.1, c.2)) } @@ -1260,7 +1303,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { 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) { + // A panel in the workspace's icon groups opens from there (Essentials Classic's Pages). + let iconic = app.ui.icon_groups.as_ref().is_some_and(|g| g.iter().flatten().any(|p| *p == panel)); + if !iconic && crate::dock::DOCK_TABS.iter().any(|(id, _, _)| *id == panel) { app.ui.dock_tab = panel; app.ui.dock_expanded = true; app.ui.open_panel = None; @@ -1437,6 +1482,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Option Option Some("properties"), "Interactive for PDF" | "Digital Publishing" => Some("pages"), @@ -1753,6 +1805,16 @@ fn parse_entries(entries: &[&str]) -> Vec { .map(|(id, label, _)| Item::Cmd { label: label.to_string(), id: "window.panel".into(), params: json!({"panel": id}), shortcut: None }) .collect(); out.push(Item::Sub("Panels".into(), items)); + } else if e == "ui:window.workspace" { + // Shown as InDesign's "[Essentials]"; Reset names the current workspace (see `menu_items`). + let mut items: Vec = WORKSPACES + .iter() + .map(|w| Item::Cmd { label: w.to_string(), id: "window.workspace".into(), params: json!({"name": w}), shortcut: None }) + .collect(); + items.push(Item::Sep); + items.push(cmd_item("window.resetWorkspace", Value::Null)); + items.push(cmd_item("window.newWorkspace", Value::Null)); + out.push(Item::Sub("Workspace".into(), items)); } else if e == "ui:window.brightness" { let items = crate::theme::Brightness::ALL .iter() @@ -1792,6 +1854,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { /// Check state of a toggle command (None = not a toggle). pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { Some(match id { + "window.workspace" => params.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()), "view.rulers" => app.ui.rulers, "view.frameEdges" => app.ui.frame_edges, "view.overprintPreview" => app.ui.overprint_preview, @@ -2089,7 +2152,12 @@ fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item], path: &str } Item::Cmd { label, .. } if !app.ui.show_full_menus && app.ui.hidden_menu_items.contains(&menu_key(path, label)) => hidden += 1, Item::Cmd { label, id, params, shortcut: _ } => { - let label = crate::i18n::tr(&app.ui.language, label); + let lang = &app.ui.language; + let label = match id.as_str() { + "window.workspace" => format!("[{}]", crate::i18n::workspace_name(lang, label)), + "window.resetWorkspace" => format!("{} {}", crate::i18n::tr(lang, "Reset"), crate::i18n::workspace_name(lang, &app.ui.workspace)), + _ => crate::i18n::tr(lang, label).to_owned(), + }; let text = match checked(app, id, params) { Some(true) => format!("✓ {label}"), Some(false) => format!(" {label}"), @@ -3379,6 +3447,74 @@ mod tests { assert_eq!(app.ui.workspace, "Essentials"); } + #[test] + fn essentials_classic_is_the_control_panel_and_iconic_groups() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + run_ui(&mut app, "window.workspace", &json!({"name": "Essentials Classic"})).unwrap().unwrap(); + assert!(app.ui.control_bar && !app.ui.dock_expanded); + let groups = app.ui.icon_groups.clone().unwrap(); + assert_eq!(groups[0], ["pages", "layers", "links"]); + assert_eq!(groups.last().unwrap(), &["gradient"]); + assert_eq!(checked(&app, "window.workspace", &json!({"name": "Essentials Classic"})), Some(true)); + assert_eq!(checked(&app, "window.workspace", &json!({"name": "Essentials"})), Some(false)); + // Window › Pages opens Pages from its icon group, not the Properties stack. + run_ui(&mut app, "window.panel", &json!({"panel": "pages"})).unwrap().unwrap(); + assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_expanded), (Some("pages"), false)); + // A workspace saved from it keeps the groups. + run_ui(&mut app, "window.newWorkspace", &json!({"name": "Mine"})).unwrap().unwrap(); + run_ui(&mut app, "window.workspace", &json!({"name": "Essentials"})).unwrap().unwrap(); + assert_eq!((app.ui.icon_groups.as_ref(), app.ui.dock_expanded, app.ui.control_bar), (None, true, false)); + run_ui(&mut app, "window.workspace", &json!({"name": "Mine"})).unwrap().unwrap(); + assert_eq!(app.ui.icon_groups, Some(groups)); + } + + #[test] + fn the_window_menu_is_in_indesigns_order_and_reaches_every_panel() { + let window = menu_tree().into_iter().find(|(m, _)| *m == "Window").unwrap().1; + let names: Vec<&str> = window + .iter() + .filter_map(|i| match i { + Item::Sub(n, _) => Some(n.as_str()), + Item::Cmd { label, .. } => Some(label.as_str()), + Item::Sep => None, + }) + .collect(); + assert_eq!(&names[..4], ["Arrange", "Workspace", "Articles", "Color"]); + let sub = |name: &str| { + window + .iter() + .find_map(|i| match i { + Item::Sub(n, c) if n == name => Some(c.clone()), + _ => None, + }) + .unwrap() + }; + // Window › Workspace › Essentials Classic, Window › Color › Gradient & Swatches. + assert!( + sub("Workspace") + .iter() + .any(|i| matches!(i, Item::Cmd { id, params, .. } if id == "window.workspace" && params["name"] == "Essentials Classic")) + ); + let color: Vec = + sub("Color").iter().filter_map(|i| if let Item::Cmd { label, .. } = i { Some(label.clone()) } else { None }).collect(); + assert_eq!(color, ["Color", "Gradient", "Swatches"]); + // Every panel is in the menu. + fn panels(items: &[Item], out: &mut Vec) { + for i in items { + match i { + Item::Sub(_, c) => panels(c, out), + Item::Cmd { id, params, .. } if id == "window.panel" => out.extend(params["panel"].as_str().map(str::to_string)), + _ => {} + } + } + } + let mut found = Vec::new(); + panels(&window, &mut found); + for (id, _, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + assert!(found.iter().any(|p| p == id), "Window menu misses the {id} panel"); + } + } + #[test] fn interactive_workspaces_show_their_panels() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..d2ab2922e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -329,7 +329,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order. - Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). -- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. +- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. Essentials Classic shows the Control panel and InDesign's iconic panel groups with names. The Window menu follows InDesign's: Arrange, Workspace, then the panels alphabetically with their Color, Editorial, Interactive, Object & Layout, Output, Styles, Type & Tables and Utilities groups. - Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting. - Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position. - Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8805defce..f9da7383b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -47,7 +47,7 @@ basics of editing fail for too many users on Windows and Linux. | Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) | | Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info | ✓ | all present | ~ | **Tabs panel missing** (#326); Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) | | Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies | -| Workspaces | Essentials, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Typography, Touch | ✓ named workspaces + custom | ~ | tab bar overlaps menus in Book/Advanced (#182) | +| Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) | | Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing | | Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | | | Closing with unsaved changes asks | ✓ | ✓ File › Close and window/menu quit (#146, #545) | ✓ | macOS Dock Quit still bypasses the prompt (#544) | From d49d7c4ea006eeaac2c98a6e0c4e4016a6f7df8e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 20:18:14 +0200 Subject: [PATCH 095/200] i18n: keep the new Window menu strings beside Workspace Co-Authored-By: Claude Opus 5.5 --- 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 | 8 ++++---- crates/ui-egui/src/i18n/pt_br.rs | 8 ++++---- crates/ui-egui/src/i18n/uk.rs | 8 ++++---- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ffa50c108..07d294c56 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,10 +55,6 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), - ("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]), - ("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]), - ("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]), - ("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]), ("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", "消去", "清除"]), @@ -195,6 +191,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Buttons and Forms", ["Schaltflächen und Formulare", "Boutons et formulaires", "Botones y formularios", "ボタンとフォーム", "按钮和表单"]), ("Liquid Layout", ["Flüssiges Layout", "Mise en page liquide", "Maquetación líquida", "リキッドレイアウト", "自适应版面"]), ("Workspace", ["Arbeitsbereich", "Espace de travail", "Espacio de trabajo", "ワークスペース", "工作区"]), + ("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]), + ("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]), + ("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]), + ("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]), ("Split Window", ["Fenster teilen", "Fractionner la fenêtre", "Dividir ventana", "ウィンドウを分割", "拆分窗口"]), ("New Window", ["Neues Fenster", "Nouvelle fenêtre", "Nueva ventana", "新規ウィンドウ", "新建窗口"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index f17794248..6a282b764 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,10 +30,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), - ("Editorial", "التحرير"), - ("Object & Layout", "الكائن والتخطيط"), - ("Styles", "الأنماط"), - ("Essentials Classic", "الأساسيات الكلاسيكية"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), @@ -130,6 +126,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Hide Rulers", "إخفاء المساطر"), ("Extras", "إضافات"), ("Workspace", "مساحة العمل"), + ("Editorial", "التحرير"), + ("Object & Layout", "الكائن والتخطيط"), + ("Styles", "الأنماط"), + ("Essentials Classic", "الأساسيات الكلاسيكية"), ("New Workspace", "مساحة عمل جديدة"), ("Properties", "خصائص"), ("Layers", "الطبقات"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index e389a0390..44902773f 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,10 +28,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), - ("Editorial", "Editoriale"), - ("Object & Layout", "Oggetto e layout"), - ("Styles", "Stili"), - ("Essentials Classic", "Essenziali classico"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), @@ -116,6 +112,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Buttons and Forms", "Pulsanti e moduli"), ("Liquid Layout", "Layout liquido"), ("Workspace", "Area di lavoro"), + ("Editorial", "Editoriale"), + ("Object & Layout", "Oggetto e layout"), + ("Styles", "Stili"), + ("Essentials Classic", "Essenziali classico"), ("Split Window", "Dividi finestra"), ("New Window", "Nuova finestra"), ("Show All Menu Items", "Mostra tutte le voci di menu"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 68a093c4d..cb3dcdcda 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,10 +30,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), - ("Editorial", "エディトリアル"), - ("Object & Layout", "オブジェクト&レイアウト"), - ("Styles", "スタイル"), - ("Essentials Classic", "基本(クラシック)"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), @@ -118,6 +114,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Buttons and Forms", "ボタンとフォーム"), ("Liquid Layout", "リキッドレイアウト"), ("Workspace", "ワークスペース"), + ("Editorial", "エディトリアル"), + ("Object & Layout", "オブジェクト&レイアウト"), + ("Styles", "スタイル"), + ("Essentials Classic", "基本(クラシック)"), ("Split Window", "ウィンドウを分割"), ("New Window", "新規ウィンドウ"), ("Show All Menu Items", "すべてのメニュー項目を表示"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 5474ceb19..210ee8d45 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,10 +31,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), - ("Editorial", "Editorial"), - ("Object & Layout", "Objeto e layout"), - ("Styles", "Estilos"), - ("Essentials Classic", "Essencial clássico"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), @@ -130,6 +126,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Hide Rulers", "Ocultar réguas"), ("Extras", "Extras"), ("Workspace", "Área de trabalho"), + ("Editorial", "Editorial"), + ("Object & Layout", "Objeto e layout"), + ("Styles", "Estilos"), + ("Essentials Classic", "Essencial clássico"), ("New Workspace", "Nova área de trabalho"), ("Properties", "Propriedades"), ("Layers", "Camadas"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 91feec029..a90e9b607 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,10 +31,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), - ("Editorial", "Редакція"), - ("Object & Layout", "Об’єкт і макет"), - ("Styles", "Стилі"), - ("Essentials Classic", "Основне (класичне)"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), @@ -119,6 +115,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Buttons and Forms", "Кнопки та форми"), ("Liquid Layout", "Гнучкий макет"), ("Workspace", "Робоче середовище"), + ("Editorial", "Редакція"), + ("Object & Layout", "Об’єкт і макет"), + ("Styles", "Стилі"), + ("Essentials Classic", "Основне (класичне)"), ("Split Window", "Розділити вікно"), ("New Window", "Нове вікно"), ("Show All Menu Items", "Показати всі пункти меню"), From 677cafbf9c3454e037ac6fd58fd498b9cc00f060 Mon Sep 17 00:00:00 2001 From: Govinda Roy Date: Wed, 14 Oct 2026 00:26:02 +0600 Subject: [PATCH 096/200] Images: EPS shows through its proxy at display time; simple PostScript (QR codes) is drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An EPS that came in with a document (InDesign embeds its QR codes as EPS) drew nothing: only File › Place converted EPS to a proxy. Now `decode_rgba` draws any EPS through `eps_proxy` and `natural_size` measures it by its bounding box in points, so screen, PNG and PDF show it while the document keeps the EPS itself. `eps_proxy` also draws PostScript that only fills rectangles in solid colours (setcmykcolor, setrgbcolor, setgray, rectfill and procedures that stand for them, as InDesign's QR codes are written), at 8 px per point so modules stay crisp. Anything else keeps the preview or placeholder. Input is bounded: tokens, rectangles, painted area and procedure definitions. Co-Authored-By: Claude Opus 5.5 --- crates/images/src/eps.rs | 147 ++++++++++++++++++++++++++++++++++++++- crates/images/src/lib.rs | 22 ++++++ 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/crates/images/src/eps.rs b/crates/images/src/eps.rs index 01d959292..f86c4eaef 100644 --- a/crates/images/src/eps.rs +++ b/crates/images/src/eps.rs @@ -1,6 +1,7 @@ -//! EPS: there's no PostScript interpreter here, so a placed EPS shows (and prints) through a -//! proxy, as InDesign does on screen: the file's TIFF preview when it has one, else a placeholder -//! the size of its bounding box. +//! EPS: there's no general PostScript interpreter here, so a placed EPS shows (and prints) +//! through a proxy, as InDesign does on screen: the file's TIFF preview when it has one, else the +//! rectangles of a simple one drawn here (InDesign's QR codes), else a placeholder the size of its +//! bounding box. /// An EPS file (plain, or DOS EPS with a binary header)? pub fn is_eps(bytes: &[u8]) -> bool { @@ -45,6 +46,10 @@ pub fn eps_proxy(bytes: &[u8]) -> Option<(Vec, (f64, f64))> { { return Some((tiff.to_vec(), (w, h))); } + // Simple PostScript drawn here: InDesign's QR codes are coloured rectangles only. + if let Some(png) = render_rectangles(sections(bytes).0, bb) { + return Some((png, (w, h))); + } let (pw, ph) = ((w * 2.0).round().clamp(2.0, 4000.0) as u32, (h * 2.0).round().clamp(2.0, 4000.0) as u32); let mut img = image::RgbaImage::from_pixel(pw, ph, image::Rgba([228, 228, 228, 255])); // Diagonals and a border mark it as a stand-in. @@ -68,10 +73,146 @@ pub fn eps_proxy(bytes: &[u8]) -> Option<(Vec, (f64, f64))> { Some((out, (w, h))) } +/// Pixels per point for rendered EPS (a QR code's modules stay crisp when scaled up on screen). +const RENDER_SCALE: f64 = 8.0; +/// The most rectangles and operators read from an EPS before giving up on drawing it. +const MAX_RECTS: usize = 200_000; +const MAX_TOKENS: usize = 2_000_000; +/// The most pixels painted, in multiples of the image's area (overlapping rectangles repaint). +const MAX_PAINT_AREAS: u64 = 64; + +/// PostScript that only fills rectangles in solid colours (`setcmykcolor`, `setrgbcolor`, +/// `setgray`, `rectfill`, and procedures that stand for one of them, such as InDesign's +/// `/rf { rectfill } bind def`), drawn as a PNG of the bounding box `bb`. None for anything else +/// (paths, text, images, transforms), which keeps its placeholder, or when nothing is drawn. +fn render_rectangles(ps: &[u8], bb: [f64; 4]) -> Option> { + let text = std::str::from_utf8(ps).ok()?; + let (w, h) = (bb[2] - bb[0], bb[3] - bb[1]); + if !(w > 0.0 && h > 0.0) { + return None; + } + let scale = RENDER_SCALE.min(4000.0 / w.max(h)); + let (pw, ph) = ((w * scale).round().max(1.0) as u32, (h * scale).round().max(1.0) as u32); + let mut img = image::RgbaImage::from_pixel(pw, ph, image::Rgba([0, 0, 0, 0])); + let mut aliases: std::collections::HashMap = std::collections::HashMap::new(); + let mut stack: Vec = Vec::new(); + let mut color = [0u8, 0, 0, 255]; + let mut drawn = 0usize; + let mut painted = 0u64; + let paint_budget = u64::from(pw) * u64::from(ph) * MAX_PAINT_AREAS; + // Tokens without comments; `{ … }` procedures kept whole. + let mut tokens: Vec = Vec::new(); + for line in text.lines() { + let line = line.split('%').next().unwrap_or(""); + let spaced = line.replace('{', " { ").replace('}', " } "); + tokens.extend(spaced.split_whitespace().map(str::to_string)); + if tokens.len() > MAX_TOKENS { + return None; + } + } + let unit = |v: f64| v.clamp(0.0, 1.0); + let mut i = 0; + while let Some(tok) = tokens.get(i) { + i += 1; + if let Ok(v) = tok.parse::() { + if !v.is_finite() { + return None; + } + stack.push(v); + continue; + } + // `/name { op } bind def`: name stands for op. + if let Some(name) = tok.strip_prefix('/') { + // At most `{ op } bind`: anything longer isn't an alias this reader knows. + let body: Vec<&String> = tokens.iter().skip(i).take(5).take_while(|t| *t != "def").collect(); + let op = match body.as_slice() { + [open, op, close] | [open, op, close, _] if *open == "{" && *close == "}" => (*op).clone(), + _ => return None, + }; + aliases.insert(name.to_string(), op); + i += body.len() + 1; + continue; + } + let op = aliases.get(tok).map_or(tok.as_str(), String::as_str); + let mut pop = |n: usize| -> Option> { (stack.len() >= n).then(|| stack.split_off(stack.len() - n)) }; + match op { + "setcmykcolor" => { + let v = pop(4)?; + let k = unit(v[3]); + let ch = |c: f64| ((1.0 - unit(c)) * (1.0 - k) * 255.0).round() as u8; + color = [ch(v[0]), ch(v[1]), ch(v[2]), 255]; + } + "setrgbcolor" => { + let v = pop(3)?; + let ch = |c: f64| (unit(c) * 255.0).round() as u8; + color = [ch(v[0]), ch(v[1]), ch(v[2]), 255]; + } + "setgray" => { + let v = pop(1)?; + let g = (unit(v[0]) * 255.0).round() as u8; + color = [g, g, g, 255]; + } + "rectfill" => { + let v = pop(4)?; + drawn += 1; + if drawn > MAX_RECTS { + return None; + } + // PostScript y runs up; the image's down. Edges round to whole pixels, so + // touching rectangles (QR code modules) meet without gaps. + let (x0, x1) = ((v[0] - bb[0]) * scale, (v[0] + v[2] - bb[0]) * scale); + let (y0, y1) = ((bb[3] - (v[1] + v[3])) * scale, (bb[3] - v[1]) * scale); + let px = |a: f64, b: f64, max: u32| { + let (lo, hi) = (a.min(b).round().max(0.0), a.max(b).round().min(max as f64)); + (lo as u32, hi.max(lo) as u32) + }; + let ((xa, xb), (ya, yb)) = (px(x0, x1, pw), px(y0, y1, ph)); + painted = painted.saturating_add(u64::from(xb - xa) * u64::from(yb - ya)); + if painted > paint_budget { + return None; + } + for y in ya..yb { + for x in xa..xb { + img.put_pixel(x, y, image::Rgba(color)); + } + } + } + "bind" | "gsave" | "grestore" | "newpath" | "showpage" | "save" | "restore" | "pop" | "end" | "begin" => {} + _ => return None, + } + } + if drawn == 0 { + return None; + } + let mut out = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn simple_rectangle_eps_is_drawn_like_a_qr_code() { + // As InDesign writes QR codes: a procedure for rectfill, a colour, then modules. + let eps = b"%!PS-Adobe-3.1 EPSF-3.0\n%%BoundingBox: 0 0 4 4\n%%EndComments\n/rf { rectfill } bind def\n0 0 0 1 setcmykcolor\n0 2 2 2 rf\n2 0 2 2 rf\n%%EOF\n"; + let (png, size) = eps_proxy(eps).unwrap(); + assert_eq!(size, (4.0, 4.0)); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (32, 32), "8 pixels per point"); + // PostScript y runs up: (0, 2)–(2, 4) is the image's top left. + assert_eq!(img.get_pixel(4, 4).0, [0, 0, 0, 255]); + assert_eq!(img.get_pixel(28, 28).0, [0, 0, 0, 255]); + assert_eq!(img.get_pixel(28, 4).0[3], 0, "undrawn stays transparent"); + assert_eq!(img.get_pixel(4, 28).0[3], 0); + // Anything else (here a path) keeps the placeholder. + let curve = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 4 4\n0 0 moveto 4 4 lineto stroke\n%%EOF\n"; + let (png, _) = eps_proxy(curve).unwrap(); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(img.get_pixel(2, 4).0, [228, 228, 228, 255]); + } + #[test] fn eps_bounding_box_and_proxies() { let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%HiResBoundingBox: 0 0 100.5 50.25\nnewpath\n%%EOF\n"; diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 4751263a4..7ce48c0a3 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -140,6 +140,10 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { let s = svg_tree(bytes)?.size(); return Some((s.width() as f64 * PT_PER_PX, s.height() as f64 * PT_PER_PX)); } + if is_eps(bytes) { + let bb = eps::bounding_box(bytes)?; + return Some((bb[2] - bb[0], bb[3] - bb[1])).filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0); + } let (w, h) = pixel_size(bytes)?; Some((w as f64, h as f64)) } @@ -159,6 +163,11 @@ pub fn decode_rgba(bytes: &[u8]) -> Option { if is_psd(bytes) { return psd::decode(bytes); } + // An EPS is drawn as its proxy (preview, simple PostScript or placeholder); the document + // keeps the EPS itself. + if is_eps(bytes) { + return decode_rgba(&eps_proxy(bytes)?.0); + } Some(decode_to_rgba8(&image::load_from_memory(bytes).ok()?)) } @@ -386,6 +395,19 @@ mod tests { b } + /// A placed EPS is measured by its bounding box in points and drawn through its proxy; the + /// bytes stay the EPS. + #[test] + fn eps_draws_through_its_proxy_at_its_bounding_box_size() { + let eps = + b"%!PS-Adobe-3.1 EPSF-3.0\n%%BoundingBox: 0 0 4 2\n%%EndComments\n/rf { rectfill } bind def\n0 0 0 1 setcmykcolor\n0 0 2 2 rf\n%%EOF\n"; + assert_eq!(natural_size(eps), Some((4.0, 2.0))); + let img = decode_rgba(eps).expect("drawn"); + assert_eq!(img.dimensions(), (32, 16)); + assert_eq!(img.get_pixel(1, 1).0, [0, 0, 0, 255], "the rectangle"); + assert_eq!(img.get_pixel(30, 1).0[3], 0, "outside it, transparent"); + } + /// CMYK TIFFs read back as their ink values; RGB ones aren't CMYK. #[test] fn cmyk_tiff_keeps_inks() { From ba4519c3821a5dfb15d27ff5819fd7a2c8527e84 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:31:54 +0200 Subject: [PATCH 097/200] Preferences: Story Editor size stays finite and in range (#821) Preferences OK stored whatever number the dialog held as the Story Editor font size. One that is not finite ("nan", "inf", or a number too large for an f32) was written to ui.json as null; the next launch could not read the file, started with the default UI settings and saved them over it on exit. The size is now kept between 8 and 36 pt, the slider's range, and a value that is not a finite number leaves the size as it was. --- crates/ui-egui/src/dialogs.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af1..e9776928e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2009,7 +2009,10 @@ pub fn confirm(app: &mut DesignApp) -> Result { let words: Vec = d.s("userWords").lines().map(str::to_string).collect(); app.run("spelling.setWords", json!({"words": words}))?; } - app.ui.story_editor_size = d.n("storyEditorSize").unwrap_or(14.0) as f32; + // The slider's range. A size that isn't finite is saved as `null`, and ui.json then fails to load. + if let Some(size) = d.n("storyEditorSize").filter(|v| v.is_finite()) { + app.ui.story_editor_size = size.clamp(8.0, 36.0) as f32; + } // Highlight options change the screen view only. app.canvas.shown = None; let view = match d.s("displayQuality").as_str() { @@ -4095,6 +4098,22 @@ mod tests { assert!(app.ui.dialog.as_ref().unwrap().b("absolutePageNumbers")); } + #[test] + fn preferences_ok_saves_a_story_editor_size_that_loads_back() { + // `ui.dialog.set` takes any value; a size that isn't finite is saved as `null`. + let sizes = [(json!("nan"), 20.0), (json!("inf"), 20.0), (json!(null), 20.0), (json!(1e39), 36.0), (json!(3), 8.0), (json!(11), 11.0)]; + for (size, want) in sizes { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.story_editor_size = 20.0; + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("storyEditorSize".into(), size); + confirm(&mut app).unwrap(); + let saved = serde_json::to_vec(&app.ui).unwrap(); + assert_eq!(crate::UiState::from_preferences_json(&saved).unwrap().story_editor_size, want); + } + } + #[test] fn new_document_vertical_scroll_reaches_last_field() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From dc712baeca972c6732af9e0af33aac4f156afb61 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 20:38:38 +0200 Subject: [PATCH 098/200] fix(color): gradients between process colours blend as inks, as InDesign shows them LYH Studio episode 6 builds every gradient from C=0 M=100 Y=0 K=0 to C=100 M=0 Y=0 K=0. InDesign blends a gradient's process stops as inks: halfway is C 50 M 50, a light lilac. DesignCraft converted both ends to RGB and blended there, so the middle came out a darker violet (117, 88, 189 instead of 135, 119, 181), and every Episode 6 gradient looked different. A midpoint between CMYK stops also became an RGB colour, so the PDF shading turned RGB with a conversion warning. - color: `Color::lerp` blends CMYK with CMYK (or grey), and grey with grey, in ink space; anything with RGB still blends in RGB. - `Gradient::display_stops`: the expanded stops plus samples through each span that isn't RGB at both ends, for renderers that interpolate in RGB; the screen renderer uses it. - pdf: a midpoint's expanded stop now keeps the stops' space; the colour-space test expects no conversion for CMYK or grey midpoints. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/color/src/gradient.rs | 39 +++++++++++++++++++++++ crates/color/src/lib.rs | 10 ++++++ crates/pdf/tests/gradient_color_spaces.rs | 5 ++- crates/render/src/lib.rs | 4 ++- crates/render/tests/gradient_inks.rs | 36 +++++++++++++++++++++ docs/target-app-parity.md | 2 +- 7 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 crates/render/tests/gradient_inks.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..1fa84857e 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** · 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-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/color/src/gradient.rs b/crates/color/src/gradient.rs index 4da4f2d84..6c19aabb1 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -103,6 +103,26 @@ impl Gradient { } out } + /// Stops for a renderer that blends between them in RGB: the expanded stops, plus samples + /// through each span that isn't RGB at both ends, so process colours keep blending as inks. + pub fn display_stops(&self) -> Vec<(f32, Color, f32)> { + const STEPS: usize = 16; + let stops = self.expanded_stops(); + let mut out: Vec<(f32, Color, f32)> = Vec::with_capacity(stops.len() * STEPS); + for (i, s) in stops.iter().enumerate() { + out.push(*s); + let Some(n) = stops.get(i + 1) else { continue }; + if matches!((s.1, n.1), (Color::Rgb { .. }, Color::Rgb { .. })) || n.0 - s.0 <= 1e-4 { + continue; + } + for j in 1..STEPS { + let t = s.0 + (n.0 - s.0) * j as f32 / STEPS as f32; + let (c, a) = self.sample(t); + out.push((t, c, a)); + } + } + out + } pub fn reverse(&mut self) { // A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors. let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect(); @@ -199,6 +219,25 @@ mod tests { assert!((mid - 0.5).abs() < 1e-5); } + #[test] + fn process_stops_blend_as_inks() { + // LYH Studio episode 6: magenta to cyan is C 50 M 50 halfway (a light lilac), not the RGB mix. + let mut g = Gradient::default(); + g.stops[0].color = Color::cmyk(0.0, 1.0, 0.0, 0.0); + g.stops[1].color = Color::cmyk(1.0, 0.0, 0.0, 0.0); + assert_eq!(g.sample(0.5).0, Color::cmyk(0.5, 0.5, 0.0, 0.0)); + let d = g.display_stops(); + assert_eq!(d.len(), 17, "sampled through the span"); + assert!(d.iter().any(|(o, c, _)| (*o - 0.5).abs() < 1e-6 && *c == Color::cmyk(0.5, 0.5, 0.0, 0.0))); + // A midpoint stays an ink too; RGB stops aren't sampled. + g.stops[0].midpoint = 0.25; + assert!(matches!(g.expanded_stops()[1].1, Color::Cmyk { .. })); + assert_eq!(Gradient::default().display_stops().len(), 2); + // Grey and CMYK meet as inks; RGB with anything else blends in RGB. + assert_eq!(Color::gray(1.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::cmyk(0.5, 0.0, 0.0, 0.5)); + assert!(matches!(Color::rgb(1.0, 0.0, 0.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::Rgb { .. })); + } + #[test] fn midpoint_shifts() { let mut g = Gradient::default(); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index bd5e915de..73a8cb269 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -170,7 +170,17 @@ impl Color { 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 { + let l = |a: f32, b: f32| a + (b - a) * t; + match (*self, *other) { + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => return Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), + (Color::Gray { k }, Color::Gray { k: k2 }) => return Color::gray(l(k, k2)), + (Color::Gray { k }, Color::Cmyk { .. }) => return Color::cmyk(0.0, 0.0, 0.0, k).lerp(other, t), + (Color::Cmyk { .. }, Color::Gray { k }) => return self.lerp(&Color::cmyk(0.0, 0.0, 0.0, k), t), + _ => {} + } let a = self.to_rgb(); let b = other.to_rgb(); Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs index c523a4187..f9892965b 100644 --- a/crates/pdf/tests/gradient_color_spaces.rs +++ b/crates/pdf/tests/gradient_color_spaces.rs @@ -78,15 +78,14 @@ fn mixed_gradient_export_survives_in_subprocess() { } } } - // Expansion inserts an RGB midpoint even when the authoring stops share CMYK or Gray. + // A midpoint's expanded stop blends in the stops' own space: CMYK or Gray stays homogeneous. for colors in [[cmyk, Color::cmyk(0.0, 1.0, 0.0, 0.0)], [gray, Color::gray(0.8)]] { for kind in [GradientKind::Linear, GradientKind::Radial] { for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { let report = export_pdf_with_report(&document(&colors, kind, 0.25, 1.0), &Cache::new(), &PdfOptions { standard, ..Default::default() }) .unwrap(); - let archival_cmyk = standard == Standard::PdfA2b && matches!(colors[0], Color::Cmyk { .. }); - assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_cmyk), "{standard:?}: {:?}", report.warnings); + assert_eq!(conversion_warnings(&report.warnings), 0, "{standard:?}: {:?}", report.warnings); assert_eq!(Pdf::new(report.bytes).unwrap().pages().len(), 1); } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..c49b3caf0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -880,7 +880,9 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { 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); 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(); + // Sampled so process colours blend as inks, as InDesign shows them (and as the PDF's CMYK + // shading prints). + let stops: Vec = g.display_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); if stops.is_empty() { return false; } diff --git a/crates/render/tests/gradient_inks.rs b/crates/render/tests/gradient_inks.rs new file mode 100644 index 000000000..8bb407a66 --- /dev/null +++ b/crates/render/tests/gradient_inks.rs @@ -0,0 +1,36 @@ +//! A gradient between process colours shows the inks' blend, as InDesign shows it. +use designcraft_color::swatch::Swatch; +use designcraft_color::{Color, Gradient, GradientKind, GradientStop, SwatchValue}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, Stroke}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{Placed, RenderOptions, Renderer}; + +#[test] +fn a_cmyk_gradient_is_c50_m50_halfway() { + let mut d = Document::new(&NewDocument { width: 100.0, height: 20.0, facing_pages: false, ..Default::default() }); + let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 }; + let gradient = + Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, Color::cmyk(0.0, 1.0, 0.0, 0.0)), stop(1.0, Color::cmyk(1.0, 0.0, 0.0, 0.0))] }; + d.swatches.push(Swatch { name: "Magenta to Cyan".into(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false }); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 20.0))); + item.fill = Fill::swatch("Magenta to Cyan"); + item.stroke = Stroke::default(); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + let img = Renderer::new().render( + &d, + &Cache::new(), + &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], + 100, + 20, + Affine::IDENTITY, + &RenderOptions { paper: false, background: Some([255; 4]), ..Default::default() }, + ); + let want = Color::cmyk(0.5, 0.5, 0.0, 0.0).to_rgba8(1.0); + let got = img.pixel(50, 10); + for ch in 0..3 { + assert!(got[ch].abs_diff(want[ch]) <= 4, "halfway {got:?}, the inks' blend is {want:?}"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..0d2f8b0f2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -436,7 +436,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level Task notes that headed ROADMAP.md: -- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. +- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Process stops (CMYK, grey) blend as inks on screen and at midpoints, so a midpoint keeps the shading CMYK. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. - CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken. From 08386d03b115dc1bd20ebf4f3c99158b5334a186 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 21:12:57 +0200 Subject: [PATCH 099/200] feat(ui): Object > Fitting with InDesign's shortcuts LYH Studio episode 6 (7:00) fills its photo frames with the caption "Fit Content to Frame (Ctrl + Shift + Alt + C)", InDesign's shortcut for Fill Frame Proportionally. DesignCraft's Fitting items were object.fit variants without shortcuts (a menu shortcut belongs to a command id, and the five modes share one). - Five UI commands, one per mode, with InDesign's keys: fit.fillFrameProportionally Cmd+Alt+Shift+C, fit.contentProportionally Cmd+Alt+Shift+E, fit.frameToContent Cmd+Alt+C, fit.contentToFrame Cmd+Alt+E, fit.centerContent Cmd+Shift+E (Ctrl on Windows and Linux); each runs object.fit. - Object > Fitting lists them (with their shortcuts), enabled as object.fit is. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/menus.rs | 72 +++++++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..ec341060e 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** · 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-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/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..657ccd3c5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -163,6 +163,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), + // Object › Fitting with InDesign's shortcuts (one `object.fit` mode each). + ("fit.fillFrameProportionally", "Fill Frame Proportionally", Some("Cmd+Alt+Shift+C"), "{} — object.fit {mode: fillProportionally}"), + ("fit.contentProportionally", "Fit Content Proportionally", Some("Cmd+Alt+Shift+E"), "{} — object.fit {mode: fitProportionally}"), + ("fit.frameToContent", "Fit Frame to Content", Some("Cmd+Alt+C"), "{} — object.fit {mode: fitFrameToContent}"), + ("fit.contentToFrame", "Fit Content to Frame", Some("Cmd+Alt+E"), "{} — object.fit {mode: fitContentToFrame}"), + ("fit.centerContent", "Center Content", Some("Cmd+Shift+E"), "{} — object.fit {mode: centerContent}"), ("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"), ("window.newWindow", "New Window", None, "{on?: bool} — another view of the active document in its own window"), ("window.taskBar", "Contextual Task Bar", None, "{}"), @@ -552,11 +558,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:anchored.release", "<", ">Fitting", - "cmd:object.fit|Fill Frame Proportionally|{\"mode\":\"fillProportionally\"}", - "cmd:object.fit|Fit Content Proportionally|{\"mode\":\"fitProportionally\"}", - "cmd:object.fit|Fit Frame to Content|{\"mode\":\"fitFrameToContent\"}", - "cmd:object.fit|Fit Content to Frame|{\"mode\":\"fitContentToFrame\"}", - "cmd:object.fit|Center Content|{\"mode\":\"centerContent\"}", + "ui:fit.fillFrameProportionally", + "ui:fit.contentProportionally", + "ui:fit.frameToContent", + "ui:fit.contentToFrame", + "ui:fit.centerContent", "-", "ui:app.fittingOptions", "<", @@ -1269,6 +1275,16 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let mode = match id { + "fit.fillFrameProportionally" => "fillProportionally", + "fit.contentProportionally" => "fitProportionally", + "fit.frameToContent" => "fitFrameToContent", + "fit.contentToFrame" => "fitContentToFrame", + _ => "centerContent", + }; + app.run("object.fit", json!({"mode": mode})) + } "app.keyboardShortcuts" => { app.ui.dialog = Some(crate::dialogs::Dialog::new("keyboardShortcuts", json!({"query": "", "recording": ""}))); Ok(Value::Null) @@ -1856,6 +1872,9 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { /// Enablement for menu display (UI commands need a document unless they're app/window-level). pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { + if id.starts_with("fit.") { + return enabled(app, "object.fit"); + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window."); } @@ -2872,6 +2891,47 @@ mod tests { assert!(run_ui(&mut app, "view.proofSetup", &json!({"target": "nope"})).unwrap().is_err()); } + #[test] + fn fitting_has_indesigns_shortcuts() { + // LYH Studio episode 6 (7:00): "Fit Content to Frame (Ctrl + Shift + Alt + C)" fills the + // photo frames proportionally. + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + let f = app.run("frame.create", json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "a.png", "frame": f})).unwrap(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + let width = |app: &crate::DesignApp| { + let it = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone(); + let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("graphic") }; + it.xf.transform_rect_bbox(g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1))).width() + }; + for (id, keys) in [ + ("fit.fillFrameProportionally", "Cmd+Alt+Shift+C"), + ("fit.contentProportionally", "Cmd+Alt+Shift+E"), + ("fit.frameToContent", "Cmd+Alt+C"), + ("fit.contentToFrame", "Cmd+Alt+E"), + ("fit.centerContent", "Cmd+Shift+E"), + ] { + assert_eq!(shortcut_of(&app, id).as_deref(), Some(keys)); + } + let ctx = egui::Context::default(); + let press = |app: &mut crate::DesignApp, key: egui::Key| { + let modifiers = + egui::Modifiers { alt: true, ctrl: !cfg!(target_os = "macos"), shift: true, mac_cmd: cfg!(target_os = "macos"), command: true }; + let input = egui::RawInput { + events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| shortcuts(app, &ui.ctx().clone())); + out.textures_delta.clear(); + }; + press(&mut app, egui::Key::C); + assert!((width(&app) - 400.0).abs() < 1e-6, "filled: {}", width(&app)); + press(&mut app, egui::Key::E); + assert!((width(&app) - 200.0).abs() < 1e-6, "fitted: {}", width(&app)); + } + #[test] fn tab_hides_panels_and_documents_cycle() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -3358,7 +3418,7 @@ mod tests { }); assert!(special.is_some_and(|c| c.iter().any(|i| matches!(i, Item::Sub(n, _) if n == "Symbols")))); // Fixed-parameter variants keep their own labels. - assert!(all.iter().any(|(l, id, p)| l == "Fill Frame Proportionally" && id == "object.fit" && p["mode"] == "fillProportionally")); + assert!(all.iter().any(|(l, id, _)| l == "Fill Frame Proportionally" && id == "fit.fillFrameProportionally")); } #[test] From efd2310c83c6936307153415e494c8704670cf09 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 13:20:47 -0600 Subject: [PATCH 100/200] 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 7a89954937937b371b29aa6d089de5cccc086105 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 13 Oct 2026 12:06:41 -0700 Subject: [PATCH 101/200] Placed PDFs: render with pdfcraft's patched hayro crates Placed PDFs rendered through hayro 0.7 from crates.io, whose hayro-interpret divides integers in calculator functions without a check, does not limit how deep functions nest and samples each mesh triangle over its whole bounding box. A placed PDF whose calculator function runs `1 0 idiv` or `-2147483648 -1 idiv` panicked the render. A chain of 100,000 functions, each stitching function naming the next, overflowed the stack and aborted the process. Mesh shadings with points far off the page, ten thousand overlapping triangles or forty thousand patches ran for over a minute or passed 2 GiB of memory. [patch.crates-io] now takes hayro, hayro-interpret, hayro-syntax and hayro-jbig2 from pdfcraft's vendor/ directory at b5467d93, with the patches its vendor/README.md lists. The crate versions do not change. The patched hayro-syntax sizes a page with a /UserUnit in points, and pdf_page_box scales the page's boxes by the same unit, so with Crop to the frame still shows the chosen box. NOTICE names the new source and the licenses. The function and mesh tests in crates/render/tests/placed_pdf.rs follow pdfcraft's tests in crates/render/src/raster.rs. --- Cargo.lock | 27 ++-- Cargo.toml | 5 + NOTICE | 5 + ROADMAP.md | 1 + crates/render/src/lib.rs | 12 +- crates/render/tests/placed_pdf.rs | 249 ++++++++++++++++++++++++++++++ 6 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 crates/render/tests/placed_pdf.rs diff --git a/Cargo.lock b/Cargo.lock index ef525c84e..a87f01c74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2004,13 +2004,13 @@ dependencies = [ [[package]] name = "hayro" version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "bytemuck", "hayro-interpret", "image", "kurbo", + "log", "pic-scale", "vello_cpu 0.0.8", ] @@ -2034,8 +2034,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2045,7 +2044,7 @@ dependencies = [ "phf", "rustc-hash 2.1.3", "siphasher", - "skrifa 0.42.1", + "skrifa 0.47.0", "smallvec", "yoke", ] @@ -2053,8 +2052,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2078,8 +2076,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "flate2", "hayro-ccitt", @@ -2088,6 +2085,7 @@ dependencies = [ "memchr", "rustc-hash 2.1.3", "smallvec", + "stringprep", "zune-jpeg", ] @@ -4199,6 +4197,17 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "subsetter" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index b08a82fc2..9ce011b57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,3 +101,8 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } +# The hayro crates from pdfcraft's vendor/ directory, with the patches its README lists. +hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } diff --git a/NOTICE b/NOTICE index 087bb6196..21123eea5 100644 --- a/NOTICE +++ b/NOTICE @@ -58,6 +58,11 @@ list (author, source, license) is in ASSETS.md. Shippori Mincho), under the SIL Open Font License 1.1. Their licence texts ship with the packages as OFL-.txt. Builds without CRAFT_FONTS_DIR do not include them. - All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0. +- hayro PDF crates (placed PDFs): hayro, hayro-interpret, hayro-syntax and hayro-jbig2 + (Apache-2.0 OR MIT) are pdfcraft's patched copies, fetched by git from its vendor/ directory at + the commit that Cargo.toml pins (https://github.com/storytold/pdfcraft, MIT OR Apache-2.0). + hayro-interpret embeds PDFium's Foxit standard-14 fonts under PDFium's BSD-style license + (vendor/hayro-interpret/assets/LICENSE_FOXIT) and a CMYK profile under CC0-1.0. Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io. diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..2c8c2ec07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,6 +147,7 @@ Newest first. - **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. - **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. +- **2026-10-13** · Placed PDFs are drawn through pdfcraft's patched hayro crates: calculator functions whose `idiv` divides by zero or overflows, chains of stitching functions a hundred thousand deep, and mesh shadings with points far off the page, ten thousand overlapping triangles or forty thousand patches no longer panic, overflow the stack, run for over a minute or use more than 2 GiB of memory. A page with a `/UserUnit`, and its trim, bleed and art boxes, are sized in points. No readiness percentage change. - **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. - **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change. - **2026-10-12** · Double-clicking the Fill or Stroke box in the Tools panel opens the Color Picker with the selection's colour, as in InDesign (it opened the Swatches panel). No readiness percentage change. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..b94281e73 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1081,18 +1081,20 @@ pub fn pdf_page_box(bytes: &[u8], page: usize, kind: &str) -> Option<(f64, f64, let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; let p = pdf.pages().get(page)?; let visible = p.intersected_crop_box(); + // The boxes are in user space units; `/UserUnit` is the size of one in points. + let unit = p.user_unit() as f64; let key: &[u8] = match kind { "trim" => keys::TRIM_BOX, "bleed" => keys::BLEED_BOX, "art" => keys::ART_BOX, - "media" => return Some(rel(visible, p.media_box())), - _ => return Some(rel(visible, visible)), + "media" => return Some(rel(visible, p.media_box(), unit)), + _ => return Some(rel(visible, visible, unit)), }; let b = p.raw().get::(key).map(|b| b.intersect(p.media_box())).unwrap_or(visible); - fn rel(v: PRect, b: PRect) -> (f64, f64, f64, f64) { - (b.x0 - v.x0, v.y1 - b.y1, b.width(), b.height()) + fn rel(v: PRect, b: PRect, unit: f64) -> (f64, f64, f64, f64) { + ((b.x0 - v.x0) * unit, (v.y1 - b.y1) * unit, b.width() * unit, b.height() * unit) } - Some(rel(visible, b)) + Some(rel(visible, b, unit)) } /// Separations Preview on a rendered image: `plate` 0–3 shows that process plate (C, M, Y, K) as diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/placed_pdf.rs new file mode 100644 index 000000000..d7487086a --- /dev/null +++ b/crates/render/tests/placed_pdf.rs @@ -0,0 +1,249 @@ +//! Placed PDFs are drawn through pdfcraft's patched hayro crates. Calculator functions whose `idiv` +//! divides by zero or overflows, stitching functions nested a hundred thousand deep, and mesh +//! shadings with points far off the page, ten thousand overlapping triangles or forty thousand +//! patches do not panic, overflow the stack or take more than a minute, and the rest of the page is +//! drawn. A page with a `/UserUnit` is sized in points, and so are its boxes. The function and mesh +//! cases follow pdfcraft's tests in `crates/render/src/raster.rs`. + +use std::sync::Arc; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::time::Duration; + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{Rendered, Renderer, pdf_page_box, pdf_page_size}; +use hayro::hayro_interpret::Function; +use hayro::hayro_syntax::Pdf; +use hayro::hayro_syntax::object::{Object, ObjectIdentifier}; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// An exponential function of one input with one output. +const LEAF: &str = "<>"; + +/// Mesh entries common to the shadings below: RGB colors, 8-bit coordinates and components. +const MESH: &str = "/ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8"; + +/// One triangle of a free-form triangle mesh: three vertices of a flag, x, y and a color. +const TRIANGLE: [u8; 18] = [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255]; + +/// A stream object with the `dict` entries, its `/Length` and `data`. +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut s = format!("<<{dict} /Length {}>>\nstream\n", data.len()).into_bytes(); + s.extend_from_slice(data); + s.extend_from_slice(b"\nendstream"); + s +} + +/// A one-page PDF whose page dictionary holds the `page` entries (boxes, resources) and whose page +/// draws `content`. The `objects` are numbered from 5. +fn pdf(page: &str, content: &str, objects: &[Vec]) -> Vec { + let mut bodies = vec![ + b"<>".to_vec(), + b"<>".to_vec(), + format!("<>").into_bytes(), + stream("", content.as_bytes()), + ]; + bodies.extend_from_slice(objects); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", bodies.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + out +} + +/// The page of a document `size` points square with `pdf` placed to fill it, rendered by +/// `Renderer::render_page` at one pixel per point. Fails when the render panics or takes more +/// than a minute. +fn placed(size: f64, pdf: Vec) -> Rendered { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut d = Document::new(&NewDocument { width: size, height: size, facing_pages: false, ..Default::default() }); + let id = AssetId(d.alloc()); + let asset = + d.add_asset(Asset { id, name: "art.pdf".into(), mime: "application/pdf".into(), link: None, data: Arc::new(pdf), pixels: None, page: 0 }); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size))); + item.content = + Content::Graphic(Graphic { asset, size: (size, size), xf: Affine::IDENTITY, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4] }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let _ = tx.send(renderer.render_page(&d, &Cache::new(), 0, 1.0, false, &Default::default())); + }); + match rx.recv_timeout(Duration::from_secs(60)) { + Ok(page) => page.expect("the page renders"), + Err(RecvTimeoutError::Timeout) => panic!("the placed PDF did not render within a minute"), + Err(RecvTimeoutError::Disconnected) => panic!("the render of the placed PDF panicked"), + } +} + +/// A 40-point page that paints an axial shading through the first of `functions` (objects 6 +/// onwards), then a blue square over the whole page. +fn shaded(functions: &[Vec]) -> Vec { + let mut objects = vec![b"<>".to_vec()]; + objects.extend_from_slice(functions); + pdf("/MediaBox[0 0 40 40]/Resources<>>>", "/S sh 0 0 1 rg 0 0 40 40 re f", &objects) +} + +/// A calculator (Type 4) function of one input that runs `program`. +fn calculator(program: &str) -> Vec { + stream("/FunctionType 4/Domain[0 1]/Range[0 1 0 1 0 1]", program.as_bytes()) +} + +/// The function that is object 5 of a PDF holding `objects`. +fn function(objects: &[&str]) -> Option { + let objects: Vec> = objects.iter().map(|o| o.as_bytes().to_vec()).collect(); + let parsed = Pdf::new(pdf("/MediaBox[0 0 20 20]", "", &objects)).unwrap(); + let object = parsed.xref().get::>(ObjectIdentifier::new(5, 0)).unwrap(); + Function::new(&object) +} + +#[test] +fn placed_pdf_function_dividing_by_zero_leaves_the_page_drawn() { + assert_eq!(placed(40.0, shaded(&[calculator("{ pop 1 0 idiv }")])).pixel(20, 20), BLUE); +} + +#[test] +fn placed_pdf_function_dividing_the_smallest_integer_by_minus_one_leaves_the_page_drawn() { + assert_eq!(placed(40.0, shaded(&[calculator("{ pop -2147483648 -1 idiv }")])).pixel(20, 20), BLUE); +} + +/// A chain of 100,000 functions: each stitching function names the next by reference, and the last +/// is an exponential function. +#[test] +fn placed_pdf_chain_of_stitching_functions_leaves_the_page_drawn() { + let mut functions: Vec> = + (7..100_006).map(|next| format!("<>").into_bytes()).collect(); + functions.push(b"<>".to_vec()); + assert_eq!(placed(40.0, shaded(&functions)).pixel(20, 20), BLUE); +} + +/// Stitching functions that each name the next through `N 0 R`: 64 evaluate, 65 are refused. +#[test] +fn function_limits_depth_guard_refuses_a_chain_of_references() { + for functions in [64, 65] { + let objects: Vec = (5..5 + functions) + .map(|number| { + if number < 4 + functions { + format!("<>", number + 1) + } else { + LEAF.to_owned() + } + }) + .collect(); + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let chain = function(&objects); + if functions == 64 { + assert_eq!(chain.unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]); + } else { + assert!(chain.is_none(), "65 functions are one past the function-depth guard"); + } + } +} + +/// Stitching functions with a cycle, no children or a missing child are refused; one child named +/// twice still evaluates. +#[test] +fn function_limits_stitching_cycles_are_refused_and_shared_children_work() { + for objects in [ + &["<>"][..], + &[ + "<>", + "<>", + ], + &["<>"], + &["<>"], + &["<>", LEAF], + ] { + assert!(function(objects).is_none(), "accepted invalid children: {objects:?}"); + } + let shared = function(&["<>", LEAF]).unwrap(); + let output = shared.eval([0.75].into_iter().collect()).unwrap(); + assert!((output[0] - 0.5).abs() < 0.001); +} + +/// Ten thousand triangles that each cover half of the placed page, which is drawn 1000 pixels +/// square. +#[test] +fn many_overlapping_mesh_triangles_finish() { + let shading = stream(&format!("/ShadingType 4 {MESH} /BitsPerFlag 8 /Decode [0 125 0 125 0 1 0 1 0 1]"), &TRIANGLE.repeat(10_000)); + let page = placed(125.0, pdf("/MediaBox[0 0 125 125]/Resources<>>>", "/Sh0 sh", &[shading])); + let p = page.pixel(12, 112); + assert!(p != WHITE && p[3] == 255, "the triangles are drawn: {p:?}"); +} + +/// One Coons patch: its flag, twelve points around a square of 8-bit coordinates, four colors. +fn coons() -> Vec { + let mut patch = vec![0u8]; + for p in [(0, 0), (0, 85), (0, 170), (0, 255), (85, 255), (170, 255), (255, 255), (255, 170), (255, 85), (255, 0), (170, 0), (85, 0)] { + patch.extend_from_slice(&[p.0, p.1]); + } + patch.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]); + patch +} + +/// A 40-point page that paints `shading` (object 5), then a red square at its bottom left. +fn meshed(shading: Vec) -> Vec { + pdf("/MediaBox[0 0 40 40]/Resources<>>>", "/Sh0 sh 1 0 0 rg 0 0 4 4 re f", &[shading]) +} + +/// Meshes whose points lie far off the page are sampled only where the page shows them. +#[test] +fn mesh_shadings_far_off_the_page_render() { + let far = "/Decode [-40000 40000 -40000 40000 0 1 0 1 0 1]"; + for (what, shading) in [ + ("a Coons patch", stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 {far}"), &coons())), + ("a triangle mesh", stream(&format!("/ShadingType 4 {MESH} /BitsPerFlag 8 {far}"), &TRIANGLE)), + ] { + assert_eq!(placed(40.0, meshed(shading)).pixel(1, 38), RED, "{what} far off the page: the rest of the page draws"); + } + for (what, decode) in [("on the page", "[0 40 0 40 0 1 0 1 0 1]"), ("covering the page", "[-40000 40000 -40000 40000 0 1 0 1 0 1]")] { + let page = placed(40.0, meshed(stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode {decode}"), &coons()))); + let center = page.pixel(20, 20); + assert!(center != WHITE && center[3] == 255, "a patch {what} is shaded: {center:?}"); + } + // A patch filling a 4 × 4 pt rectangle as a pattern is still shaded. + let shading = stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode [10 14 10 14 0 1 0 1 0 1]"), &coons()); + let page = placed( + 40.0, + pdf("/MediaBox[0 0 40 40]/Resources<>>>>>", "/Pattern cs /P0 scn 10 10 4 4 re f", &[shading]), + ); + let middle = page.pixel(12, 28); + assert!(middle != WHITE && middle[3] == 255, "a small patch is shaded: {middle:?}"); +} + +/// Forty thousand Coons patches beside the page, and a lattice with no vertices per row. +#[test] +fn mesh_shadings_with_many_patches_or_no_rows_finish() { + for shading in [ + stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode [100 140 100 140 0 1 0 1 0 1]"), &coons().repeat(40_000)), + stream(&format!("/ShadingType 5 {MESH} /VerticesPerRow 0 /Decode [0 40 0 40 0 1 0 1 0 1]"), &[0; 64]), + ] { + assert_eq!(placed(40.0, meshed(shading)).pixel(1, 38), RED, "the rest of the page draws"); + } +} + +/// A page with `/UserUnit 2` is twice its media box in points, and Image Import Options › Crop +/// to measures the trim box in points as well. +#[test] +fn a_page_with_a_user_unit_and_its_boxes_are_sized_in_points() { + let page = pdf("/MediaBox[0 0 100 50]/TrimBox[10 10 90 40]/UserUnit 2", "", &[]); + assert_eq!(pdf_page_size(&page, 0), Some((200.0, 100.0))); + assert_eq!(pdf_page_box(&page, 0, "trim"), Some((20.0, 20.0, 160.0, 60.0))); + assert_eq!(pdf_page_box(&page, 0, "media"), Some((0.0, 0.0, 200.0, 100.0))); +} 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 102/200] 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 f4f2f0bb08bd3fa5426bc77f2e8af1f0124796a1 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:20:41 +1000 Subject: [PATCH 103/200] FIX-TABLE-SELECTION: resolve selected merged-cell owners Co-Authored-By: Codex --- crates/doc/src/table.rs | 48 +++++ crates/engine/src/cmd/table.rs | 129 ++++++------- crates/engine/src/cmd/text.rs | 11 +- crates/engine/src/tests_table.rs | 315 +++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 76 +++++++- 5 files changed, 503 insertions(+), 76 deletions(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 9449be93c..087412bc2 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -444,6 +444,25 @@ impl Table { v } + /// Owning cells touched by the range, once each in row-major order. Bounds are clipped + /// to the grid; resolving covered positions does not expand the selection rectangle. + pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> { + let (nr, nc) = (self.nrows(), self.ncols()); + if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 { + return Vec::new(); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1.min(nr - 1) { + for c in range.c0..=range.c1.min(nc - 1) { + if let Some(&owner) = owners.get(r * nc + c) { + selected.insert(owner); + } + } + } + selected.into_iter().collect() + } + /// The owning (top-left) cell address of grid position (r, c). pub fn owner(&self, r: usize, c: usize) -> (usize, usize) { self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c)) @@ -1136,6 +1155,35 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + #[test] + fn selected_owners_include_covered_cells_without_expanding_range() { + for column in [true, false] { + let mut t = Table::new(1, 2, 2, 0, 0, 120.0); + let range = if column { CellRange::new(0, 1, 1, 1) } else { CellRange::new(1, 0, 1, 1) }; + assert_eq!(t.selected_owners(range), if column { vec![(0, 1), (1, 1)] } else { vec![(1, 0), (1, 1)] }); + t.merge(if column { CellRange::new(0, 0, 0, 1) } else { CellRange::new(0, 0, 1, 0) }).unwrap(); + assert_eq!(t.selected_owners(range), vec![(0, 0), (1, 1)]); + } + } + + #[test] + fn selected_owners_are_bounded_sorted_and_unique() { + let mut t = Table::new(1, 3, 3, 0, 0, 180.0); + t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + assert_eq!(t.selected_owners(CellRange::new(1, 1, usize::MAX, usize::MAX)), vec![(0, 0), (1, 2), (2, 1), (2, 2)]); + assert_eq!(t.selected_owners(CellRange::new(0, 0, 1, 1)), vec![(0, 0)], "covered positions do not duplicate an owner"); + assert!(t.selected_owners(CellRange::new(3, 0, usize::MAX, usize::MAX)).is_empty()); + assert!(t.selected_owners(CellRange::new(0, 3, usize::MAX, usize::MAX)).is_empty()); + assert!(t.selected_owners(CellRange { r0: 1, c0: 0, r1: 0, c1: 1 }).is_empty()); + t.rows.clear(); + t.cells.clear(); + assert!(t.selected_owners(CellRange::cell(0, 0)).is_empty()); + + let mut right = Table::new(2, 2, 2, 0, 0, 120.0); + right.merge(CellRange::new(0, 1, 1, 1)).unwrap(); + assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order"); + } + #[test] fn insert_table_gets_own_paragraph() { let mut s = story("before after"); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index ad306be8e..c3df24420 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -7,6 +7,7 @@ //! whole table). use designcraft_compose as compose; +use designcraft_doc::story::floor_char_boundary; use designcraft_doc::{ AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, Selection, StoryId, StrokeType, Table, TableSel, TextSel, VerticalJustification, @@ -140,11 +141,9 @@ fn table_specs() -> Vec { |s, p| { let g = target(s, p, "table.textCell")?; edit_table(s, &g, "table.textCell", |t| { - for r in g.range.r0..=g.range.r1 { - for c in g.range.c0..=g.range.c1 { - if let Some(cell) = t.cell_mut(r, c) { - cell.graphic = None; - } + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cell.graphic = None; } } ok() @@ -351,13 +350,17 @@ fn clamp_selection(d: &Document, sel: &mut Selection) { match story.and_then(|x| x.tables.get(&c.table)) { Some(tb) => { let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1)); - let len = tb.cell(r, col).map_or(0, |x| x.text.len()); - sel.text = - Some(TextSel { anchor: t.anchor.min(len), focus: t.focus.min(len), cell: Some(CellAddr { table: c.table, row: r, col }), ..t }); + let text = tb.cell(r, col).map_or("", |x| x.text.text.as_str()); + sel.text = Some(TextSel { + anchor: floor_char_boundary(text, t.anchor), + focus: floor_char_boundary(text, t.focus), + cell: Some(CellAddr { table: c.table, row: r, col }), + ..t + }); } None => { - let len = story.map_or(0, |x| x.len()); - sel.text = Some(TextSel { anchor: t.anchor.min(len), focus: t.anchor.min(len), cell: None, ..t }); + let pos = story.map_or(0, |x| floor_char_boundary(&x.text, t.anchor)); + sel.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t }); } } } @@ -648,7 +651,6 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = p.get("stroke").cloned(); edit_table(s, &g, "table.setCell", |t| { - let owners = t.owners(); let nc = t.ncols(); let nr = t.nrows(); let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; @@ -656,50 +658,45 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { // Local edits outrank previously imported edges, including explicit zero/None. let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 }; let rg = g.range; - for r in rg.r0..=rg.r1 { - for c in rg.c0..=rg.c1 { - if owners[r * nc + c] != (r, c) { - continue; - } - let Some(cell) = t.cell_mut(r, c) else { continue }; - if let Some(f) = &fill { - cell.fill = f.clone(); - } - if let Some(ti) = tint { - cell.fill_tint = ti; - } - if let Some(i) = insets { - cell.insets = i; - } - if let Some(v) = vj { - cell.vj = v; - } - if let Some(tx) = &text { - let len = cell.text.len(); - cell.text.replace(0..len, tx); - } - if let Some(sv) = &stroke { - let edges = str_param(sv, "edges").unwrap_or("all"); - let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); - let at_right = c.saturating_add(cell.col_span.max(1) as usize); - let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; - for (i, on) in [ - ("top", r == rg.r0), - ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), - ("bottom", at_bottom.saturating_sub(1) == rg.r1), - ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), - ] - .iter() - .enumerate() - .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) - { - if on { - let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; - cell.strokes[i] = parse_stroke(sv, base.clone()); - cell.stroke_defined[i] = true; - cell.stroke_priorities[i] = priority; - cell.border_overrides[i] = true; - } + for (r, c) in t.selected_owners(rg) { + let Some(cell) = t.cell_mut(r, c) else { continue }; + if let Some(f) = &fill { + cell.fill = f.clone(); + } + if let Some(ti) = tint { + cell.fill_tint = ti; + } + if let Some(i) = insets { + cell.insets = i; + } + if let Some(v) = vj { + cell.vj = v; + } + if let Some(tx) = &text { + let len = cell.text.len(); + cell.text.replace(0..len, tx); + } + if let Some(sv) = &stroke { + let edges = str_param(sv, "edges").unwrap_or("all"); + let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); + let at_right = c.saturating_add(cell.col_span.max(1) as usize); + let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; + for (i, on) in [ + ("top", r == rg.r0), + ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), + ("bottom", at_bottom.saturating_sub(1) == rg.r1), + ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), + ] + .iter() + .enumerate() + .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) + { + if on { + let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; + cell.strokes[i] = parse_stroke(sv, base.clone()); + cell.stroke_defined[i] = true; + cell.stroke_priorities[i] = priority; + cell.border_overrides[i] = true; } } } @@ -707,6 +704,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { // Keep both copies of unmerged shared boundaries consistent. Left/right name // physical sides, also in RTL tables. if let Some(sv) = &stroke { + let owners = t.owners(); let edges = str_param(sv, "edges").unwrap_or("all"); let selected = |side: &str| edges == "all" || edges == "outer" || edges == side; if selected("top") @@ -1095,7 +1093,10 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result { let cell = d .story(g.story) .and_then(|st| st.tables.get(&g.table)) - .and_then(|t| t.cell(g.range.r0, g.range.c0)) + .and_then(|t| { + let (r, c) = t.owner(g.range.r0, g.range.c0); + t.cell(r, c) + }) .ok_or_else(|| bad("style.cell.create", "no cell"))?; cs.fill = Some(cell.fill.clone()); cs.fill_tint = Some(cell.fill_tint); @@ -1122,17 +1123,11 @@ fn cell_style_apply(s: &mut Session, p: &Value) -> Result { let cs = designcraft_doc::resolve_cell_style(styles, &name); let g = target(s, p, "style.cell.apply")?; edit_table(s, &g, "style.cell.apply", |t| { - let owners = t.owners(); - let nc = t.ncols(); let mut n = 0; - for r in g.range.r0..=g.range.r1 { - for c in g.range.c0..=g.range.c1 { - if owners[r * nc + c] == (r, c) - && let Some(cell) = t.cell_mut(r, c) - { - cs.apply_to(cell); - n += 1; - } + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cs.apply_to(cell); + n += 1; } } Ok(json!({"cells": n})) @@ -1353,7 +1348,7 @@ fn drop_cells(s: &mut Session, p: &Value) -> Result { && ts.story == sid && ts.table == tid && tid2 == tid - && ts.range.contains(fr, fc) + && s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).is_some_and(|t| t.selected_owners(ts.range).binary_search(&(fr, fc)).is_ok()) { let (nr, nc) = { let t = s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).ok_or_else(|| bad(ID, "no table"))?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e88a..a53e6e8d2 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -823,14 +823,9 @@ pub(crate) fn format_targets(s: &Session) -> Vec { && let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table)) { let mut v = Vec::new(); - let owners = t.owners(); - for r in ts.range.r0..=ts.range.r1.min(t.nrows().saturating_sub(1)) { - for c in ts.range.c0..=ts.range.c1.min(t.ncols().saturating_sub(1)) { - if owners[r * t.ncols() + c] == (r, c) { - let len = t.cell(r, c).map_or(0, |x| x.text.len()); - v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); - } - } + for (r, c) in t.selected_owners(ts.range) { + let len = t.cell(r, c).map_or(0, |x| x.text.len()); + v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); } return v; } diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 3934aabec..c3a575d0d 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -16,6 +16,321 @@ fn check(s: &Session) { s.doc().unwrap().doc.check().unwrap(); } +#[test] +fn table_text_replacement_floors_utf8_selection_and_allows_quote_typing() { + for (original, (a, b), replacement, (after_a, after_b), quoted) in [ + ("a", (1, 1), "é", (0, 0), Some("“é")), // Exact caret/quote baseline. + ("abc", (3, 3), "🙂", (0, 0), Some("“🙂")), + ("ab", (1, 2), "éX", (0, 2), None), + ("ab", (2, 1), "éX", (2, 0), None), + ("abcdef", (3, 5), "🙂éX", (0, 4), None), + ("abcdef", (5, 3), "🙂éX", (4, 0), None), + ("abc", (1, 2), "🙂", (0, 0), None), // Both endpoints floor to the same boundary. + ("abcdef", (2, 6), "é🙂", (2, 6), None), // Valid Unicode endpoints stay fixed. + ("abcdef", (1, 4), "ABCDEF", (1, 4), None), + ("abcdef", (4, 1), "ABCDEF", (4, 1), None), + ("abcdef", (6, 6), "é", (2, 2), None), // Shortening clamps to EOF. + ("abc", (1, 3), "", (0, 0), None), + ] { + 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": ""})).unwrap(); + let sid = frame["story"].as_u64().unwrap(); + let fid = frame["id"].as_u64().unwrap(); + let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap(); + // Real commands position the child selection; text.select addresses parent stories. + for _ in a..original.len() { + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + } + let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) }; + for _ in 0..steps { + s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap(); + } + let mut expected = s.doc().unwrap().selection.clone(); + let selected = expected.text.as_mut().unwrap(); + assert_eq!((selected.anchor, selected.focus), (a, b)); + assert_eq!(selected.frame, Some(doc::ItemId(fid))); + assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 })); + (selected.anchor, selected.focus) = (after_a, after_b); + let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text.clone(); + s.execute("table.setCell", &json!({"text": replacement})).unwrap(); + assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, replacement); + assert_eq!(s.doc().unwrap().selection, expected, "{original}: {a}->{b}, replacement {replacement}"); + assert!(replacement.is_char_boundary(after_a) && replacement.is_char_boundary(after_b)); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, parent); + if let Some(quoted) = quoted { + assert!(s.prefs.typographers_quotes); + s.execute("text.insert", &json!({"text": "\""})).unwrap(); + assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, quoted); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus) = (3, 3); // Opening curly quote is three bytes. + assert_eq!(s.doc().unwrap().selection, expected); + } + check(&s); + } +} + +#[test] +fn table_edit_after_anchor_removal_floors_parent_caret() { + for (original, (a, b), replacement, pos, quoted, typed_pos) in [("ab", (1, 2), "é", 0, "“é\n", 3), ("abcde", (5, 1), "é🙂", 2, "é”🙂\n", 5)] + { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let other = s.execute("frame.create", &json!({"rect": [320, 36, 580, 200], "content": "text"})).unwrap(); + let other_sid = other["story"].as_u64().unwrap(); + let other_tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "keep", "raw": true})).unwrap(); + let mut expected_other = table_for(&s, other_sid, other_tid).clone(); + expected_other.cell_mut(0, 0).unwrap().insets = [7.0; 4]; + + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + let sid = frame["story"].as_u64().unwrap(); + let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap(); + for _ in a..original.len() { + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + } + let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) }; + for _ in 0..steps { + s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap(); + } + let mut expected = s.doc().unwrap().selection.clone(); + let selected = expected.text.unwrap(); + assert_eq!((selected.anchor, selected.focus), (a, b)); + assert_eq!(selected.frame, Some(doc::ItemId(frame["id"].as_u64().unwrap()))); + assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 })); + // Explicit parent replacement removes its anchor, leaving the child address stale. + assert_eq!( + s.execute("find.change", &json!({"story": sid, "find": doc::TABLE_ANCHOR.to_string(), "change": replacement})).unwrap()["count"], + 1 + ); + let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap(); + assert_eq!(parent.text, format!("{replacement}\n")); + assert!(!parent.tables.contains_key(&tid)); + assert_eq!(s.doc().unwrap().selection, expected); + assert!(!parent.text.is_char_boundary(selected.anchor)); + // An explicit edit to the surviving table reaches the disappeared-table fallback. + s.execute("table.setCell", &json!({"story": other_sid, "table": other_tid, "insets": 7})).unwrap(); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus, selected.cell) = (pos, pos, None); // Both floor the OLD anchor. + assert_eq!(s.doc().unwrap().selection, expected); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, format!("{replacement}\n")); + assert_eq!(table_for(&s, other_sid, other_tid), &expected_other); + s.execute("text.insert", &json!({"text": "\""})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, quoted); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus) = (typed_pos, typed_pos); + assert_eq!(s.doc().unwrap().selection, expected); + assert_eq!(table_for(&s, other_sid, other_tid), &expected_other); + check(&s); + } +} + +fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) { + let (mut s, sid, fid) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + for (i, text) in ["A", "B", "C", "D"].iter().enumerate() { + s.execute("text.insert", &json!({"text": text})).unwrap(); + if i < 3 { + s.execute("table.nextCell", &json!({})).unwrap(); + } + } + let merge = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", &merge).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + s.execute("table.select", &json!({"rows": [1, 1], "cols": [1, 1]})).unwrap(); + s.execute(if column { "table.selectColumn" } else { "table.selectRow" }, &json!({})).unwrap(); + (s, sid, tid, fid) +} + +#[test] +fn selected_merged_owners_receive_text_formatting_and_character_styles() { + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let selected = s.doc().unwrap().selection.clone(); + assert_eq!(selected.cells.unwrap().range, if column { doc::CellRange::new(0, 1, 1, 1) } else { doc::CellRange::new(1, 0, 1, 1) }); + let before = table_for(&s, sid, tid).clone(); + let neighbor = if column { (1, 0) } else { (0, 1) }; + s.execute("type.char", &json!({"attrs": {"size": 23}})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + let cell = table_for(&s, sid, tid).cell(r, c).unwrap(); + assert!(cell.text.runs().all(|(_, f)| f.over.size == Some(23.0)), "all runs of owner {r},{c}"); + assert_eq!(cell.text.text, before.cell(r, c).unwrap().text.text); + } + assert_eq!(table_for(&s, sid, tid).cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(s.doc().unwrap().selection, selected); + s.execute("style.character.create", &json!({"name": "Selected owners", "chars": {"tracking": 37}})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Selected owners"})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + assert!(table_for(&s, sid, tid).cell(r, c).unwrap().text.runs().all(|(_, f)| f.style == "Selected owners")); + } + let after = table_for(&s, sid, tid); + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.rows, before.rows); + assert_eq!(after.columns, before.columns); + assert_eq!(s.doc().unwrap().selection, selected, "formatting preserves the original grid rectangle"); + check(&s); + } +} + +#[test] +fn selected_merged_owners_receive_cell_options_and_unique_style_counts() { + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let range = s.doc().unwrap().selection.cells.unwrap().range; + let before = table_for(&s, sid, tid).clone(); + let neighbor = if column { (1, 0) } else { (0, 1) }; + let covered = if column { (0, 1) } else { (1, 0) }; + s.execute("table.setCell", &json!({"fill": "[Black]", "insets": 7, "text": "REPLACED"})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + let cell = table_for(&s, sid, tid).cell(r, c).unwrap(); + assert_eq!(serde_json::to_value(&cell.fill).unwrap(), json!("[Black]")); + assert_eq!(cell.insets, [7.0; 4]); + assert_eq!(cell.text.text, "REPLACED"); + } + s.execute("style.cell.create", &json!({"name": "Selected cell owners", "insets": 9})).unwrap(); + assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 2); + let after = table_for(&s, sid, tid); + for (r, c) in [(0, 0), (1, 1)] { + assert_eq!(after.cell(r, c).unwrap().style, "Selected cell owners"); + assert_eq!(after.cell(r, c).unwrap().insets, [9.0; 4]); + } + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1)); + assert_eq!(after.rows, before.rows); + assert_eq!(after.columns, before.columns); + assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, range); + let merged = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", &merged).unwrap(); + assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 1); + check(&s); + } +} + +#[test] +fn covered_owner_strokes_keep_literal_edge_masks_and_neighbors() { + for rtl in [false, true] { + for outer in [false, true] { + let (mut s, sid, tid, _) = selected_merged_table(true); + if rtl { + s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap(); + } + s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); + let before = table_for(&s, sid, tid).clone(); + s.execute("table.setCell", &json!({"stroke": {"weight": 3, "edges": if outer { "outer" } else { "inner" }}})).unwrap(); + let after = table_for(&s, sid, tid); + let mask = if rtl { [true, true, true, false] } else { [true, false, true, true] }; + let expected = mask.map(|edge| if outer { edge } else { !edge }); + let owner = after.cell(0, 0).unwrap(); + assert_eq!(owner.border_overrides, expected); + assert_eq!(owner.stroke_defined, expected); + for (i, on) in expected.into_iter().enumerate() { + if on { + assert_eq!(owner.strokes[i].weight, 3.0); + assert_eq!(owner.stroke_priorities[i], 1); + } else { + assert_eq!(owner.strokes[i], before.cell(0, 0).unwrap().strokes[i]); + } + } + for (r, c) in [(0, 1), (1, 0), (1, 1)] { + assert_eq!(after.cell(r, c), before.cell(r, c), "strict merged/covered mirror guard preserves {r},{c}"); + } + assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, doc::CellRange::cell(0, 1)); + check(&s); + } + } +} + +#[test] +fn cell_style_sampling_resolves_covered_point_but_retains_upper_left_policy() { + let (mut s, sid, tid, _) = selected_merged_table(true); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "insets": 7})).unwrap(); + s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); + let selection = s.doc().unwrap().selection.clone(); + let before = table_for(&s, sid, tid).clone(); + s.execute("style.cell.create", &json!({"name": "Covered", "fromSelection": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Covered").unwrap().insets, Some([7.0; 4])); + assert_eq!(table_for(&s, sid, tid), &before); + assert_eq!(s.doc().unwrap().selection, selection); + + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.select", &json!({"rows": [0, 1], "cols": [1, 1]})).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + for (row, col, insets, size) in [(0, 1, 7, 17), (1, 0, 11, 29)] { + s.execute("table.setCell", &json!({"row": row, "col": col, "text": "Text", "insets": insets})).unwrap(); + s.execute("table.select", &json!({"rows": [row, row], "cols": [col, col]})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": size}})).unwrap(); + } + s.execute("table.selectRow", &json!({})).unwrap(); + let before = table_for(&s, sid, tid).clone(); + let selection = s.doc().unwrap().selection.clone(); + // Row-major owners are [(0,1),(1,0)], while the rectangle starts at (1,0). + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 17.0); + s.execute("style.cell.create", &json!({"name": "Upper left", "fromSelection": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Upper left").unwrap().insets, Some([11.0; 4])); + assert_eq!(table_for(&s, sid, tid), &before); + assert_eq!(s.doc().unwrap().selection, selection); +} + +#[test] +fn selected_merged_owners_convert_graphics_back_to_text() { + let png = designcraft_render::Rendered { width: 1, height: 1, pixels: vec![255, 0, 0, 255] }.to_png(); + let image = crate::cmd::base64_encode(&png); + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let neighbor = if column { (1, 0) } else { (0, 1) }; + let covered = if column { (0, 1) } else { (1, 0) }; + for (r, c) in [covered, (1, 1), neighbor] { + let placed = s.execute("table.placeGraphic", &json!({"row": r, "col": c, "base64": image, "name": "cell.png"})).unwrap(); + if (r, c) == covered { + assert_eq!((placed["row"].as_u64(), placed["col"].as_u64()), (Some(0), Some(0))); + } + } + let before = table_for(&s, sid, tid).clone(); + let selected = s.doc().unwrap().selection.cells.unwrap(); + s.execute("table.textCell", &json!({})).unwrap(); + let after = table_for(&s, sid, tid); + for (r, c) in [(0, 0), (1, 1)] { + assert!(before.cell(r, c).unwrap().graphic.is_some()); + assert!(after.cell(r, c).unwrap().graphic.is_none()); + assert_eq!(after.cell(r, c).unwrap().text, before.cell(r, c).unwrap().text); + } + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1)); + assert_eq!(s.doc().unwrap().selection.cells, Some(selected)); + check(&s); + } +} + +#[test] +fn covered_selected_owner_drag_keeps_merged_move_restrictions_atomic() { + for column in [true, false] { + let (mut s, sid, _, fid) = selected_merged_table(column); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let revision = s.doc().unwrap().revision; + let undo = s.doc().unwrap().history.undo.len(); + let redo = s.doc().unwrap().history.redo.len(); + let cs = s.cache.get(&before, doc::StoryId(sid), None); + let cells = &cs.frames[0].tables[0].cells; + let point = |row, col| { + let cell = cells.iter().find(|c| c.row == row && c.col == col).unwrap(); + before.text_xf(before.item(doc::ItemId(fid)).unwrap()) * cell.rect.center() + }; + let from = point(0, 0); + let to = if column { point(1, 0) } else { point(0, 1) }; + let err = s.execute("table.dropCells", &json!({"frame": fid, "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap_err(); + assert!(err.to_string().contains(if column { "unmerge cells that span columns" } else { "unmerge cells that span rows" }), "{err}"); + let st = s.doc().unwrap(); + assert_eq!(st.doc, before); + assert_eq!(st.selection, selection); + assert_eq!(st.revision, revision); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, redo)); + } +} + #[test] fn insert_type_in_cells_and_tab() { let (mut s, sid, _) = session_with_frame(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..cc7cee3d7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -946,11 +946,12 @@ fn draw_text_selection( let range = ts.range(); // 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(); for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); for t in ft.tables.iter().filter(|t| t.table == sel.table) { - for c in t.cells.iter().filter(|c| sel.range.contains(c.row, c.col)) { + for c in t.cells.iter().filter(|c| selected.binary_search(&(c.row, c.col)).is_ok()) { let q = [ xf.to_screen(m * Point::new(c.rect.x0, c.rect.y0)), xf.to_screen(m * Point::new(c.rect.x1, c.rect.y0)), @@ -1805,6 +1806,79 @@ mod tests { (s.horizontal_units, s.vertical_units) } + #[test] + fn selected_merged_owners_paint_the_same_row_and_column_targets() { + for column in [true, false] { + for merged in [false, true] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 1})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap(); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + if merged { + let range = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + app.run("table.select", range).unwrap(); + app.run("table.merge", json!({})).unwrap(); + } + app.run("table.select", json!({"rows": [1, 1], "cols": [1, 1]})).unwrap(); + app.run(if column { "table.selectColumn" } else { "table.selectRow" }, json!({})).unwrap(); + let st = app.session.doc().unwrap(); + let selection = st.selection.clone(); + let ts = selection.text.unwrap(); + let selected = selection.cells.unwrap(); + assert_eq!( + selected.range, + if column { designcraft_doc::CellRange::new(0, 1, 1, 1) } else { designcraft_doc::CellRange::new(1, 0, 1, 1) } + ); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cs = app.session.cache.get(&st.doc, ts.story, None); + let ft = &cs.frames[0]; + let table = ft.tables.iter().find(|t| t.table == selected.table).unwrap(); + let (a, _) = item_canvas_xf(&st.doc, &layout, ft.frame).unwrap(); + let m = a * st.doc.text_xf(st.doc.item(ft.frame).unwrap()); + let xf = Xf { min: pos2(0.0, 0.0), origin: Point::new(0.0, 0.0), zoom: 1.0, rot: 0, pivot: pos2(0.0, 0.0) }; + let owners = if merged { + [(0, 0), (1, 1)] + } else if column { + [(0, 1), (1, 1)] + } else { + [(1, 0), (1, 1)] + }; + let expected: Vec<_> = owners + .into_iter() + .map(|(r, c)| { + let rect = table.cells.iter().find(|cell| cell.row == r && cell.col == c).unwrap().rect; + vec![ + xf.to_screen(m * Point::new(rect.x0, rect.y0)), + xf.to_screen(m * Point::new(rect.x1, rect.y0)), + xf.to_screen(m * Point::new(rect.x1, rect.y1)), + xf.to_screen(m * Point::new(rect.x0, rect.y1)), + ] + }) + .collect(); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + draw_text_selection(&app, ui.painter(), &xf, &st.doc, &layout, ts, Some(selected)); + }); + output.textures_delta.clear(); + let tint = Color32::from_rgba_unmultiplied(80, 140, 255, 90); + let painted: Vec<_> = output + .shapes + .into_iter() + .filter_map(|s| match s.shape { + egui::Shape::Path(path) if path.fill == tint => { + assert!(path.closed); + assert_eq!(path.stroke, egui::epaint::PathStroke::NONE); + Some(path.points) + } + _ => None, + }) + .collect(); + assert_eq!(painted, expected, "only the two touched owners are tinted; column={column}, merged={merged}"); + assert_eq!(app.session.doc().unwrap().selection, selection); + } + } + } + #[test] fn right_clicking_a_ruler_sets_that_rulers_units() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 3539a72b2f30e7f42031a65452d4ad69473d6463 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:23:48 +0200 Subject: [PATCH 104/200] Canvas: baseline grid draws only the lines on the page (#844) The grid was walked from its start, one line per step, with no bound. A start far above the page (from document.preferences, a saved file or an IDML package) made the next frame draw lines until the app ran out of memory. The lines are now the ones inside the page, found without walking those above it, and never more than one per point of the tallest page. --- crates/ui-egui/src/canvas.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..45cfbbe68 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -584,6 +584,17 @@ fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32, painter.extend(egui::Shape::dashed_line(&[a, b], stroke, dash, gap)); } +/// The most baseline grid lines drawn on a page: one per point of the tallest page (216 in). +const MAX_BASELINES: usize = 216 * 72; + +/// Baseline grid lines inside a page, measured from its top: `start + n * increment` for n >= 0 (the bottom +/// edge is not a line). Lines above the page are skipped without walking them, however far up the start is. +fn baseline_grid_lines(start: f64, increment: f64, height: f64) -> impl Iterator { + let step = increment.max(1.0); + let first = if start < 0.0 { start.rem_euclid(step) } else { start }; + (0..MAX_BASELINES).map(move |n| first + n as f64 * step).take_while(move |y| *y < height) +} + fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let s = &doc.settings; for slot in &layout.slots { @@ -612,13 +623,11 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document // Baseline grid. if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold { let g = &s.baseline_grid; - let mut y = g.start; - while y < p.height { + for y in baseline_grid_lines(g.start, g.increment, p.height) { painter.line_segment( [pt(Point::new(pr.x0, pr.y0 + y)), pt(Point::new(pr.x1, pr.y0 + y))], Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)), ); - y += g.increment.max(1.0); } } // Margins (magenta) and columns (violet). @@ -1918,4 +1927,18 @@ mod tests { right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0)); assert!(h.query_by_label(" Points").is_none()); } + + #[test] + fn baseline_grid_lines_are_the_ones_on_the_page() { + // A start far above the page used to be walked line by line, without end. + for start in [-1e9, -1e17] { + let mut far = baseline_grid_lines(start, 12.0, 792.0); + assert_eq!((far.next(), far.count()), (Some(8.0), 65)); + } + let lines = |start, increment, height| baseline_grid_lines(start, increment, height).collect::>(); + assert_eq!(lines(36.0, 12.0, 72.0), [36.0, 48.0, 60.0], "the bottom edge is not a line"); + assert_eq!(lines(-30.0, 12.0, 30.0), [6.0, 18.0], "a start above the page keeps its rhythm"); + assert_eq!(lines(f64::NAN, 12.0, 792.0), [0.0; 0]); + assert_eq!(baseline_grid_lines(0.0, 12.0, f64::INFINITY).count(), MAX_BASELINES); + } } From 129adfa42921d166b962e7b554e32aa2ba8bb90d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 20:41:56 +0000 Subject: [PATCH 105/200] Layout: Table of Contents dialog with entry styles, page number position and IDML TOC styles (#755) --- ROADMAP.md | 1 + crates/doc/src/lib.rs | 27 ++++ crates/engine/src/cmd/toc.rs | 220 +++++++++++++++++++++++--- crates/engine/src/tests_idml.rs | 30 ++++ crates/idml/src/export.rs | 46 +++++- crates/idml/src/import.rs | 43 ++++- crates/idml/src/names.rs | 26 ++++ crates/ui-egui/src/dialogs.rs | 267 +++++++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 4 + docs/target-app-parity.md | 2 +- 10 files changed, 641 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..b61dc9530 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** · Layout ▸ Table of Contents opens a real dialog: title and title style, Include Paragraph Styles and Other Styles lists with Add and Remove, and per style its entry style, page number position (after, before, none), separator and level. Replace Existing regenerates the current table of contents in place; otherwise OK makes a new frame on the page in view. IDML export writes the definition as a TOC style that import reads back, so Update works after a round trip. 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/doc/src/lib.rs b/crates/doc/src/lib.rs index a643cc08d..337b024f4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -584,6 +584,30 @@ pub struct TocEntry { /// 1-based nesting level. #[serde(default = "one")] pub level: u8, + /// Paragraph style of this style's entries; `None` is the generated "TOC Level n" style. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_style: Option, + #[serde(default)] + pub page_number: TocPageNumber, + /// Text between the entry and its page number; `None` is a tab. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub separator: Option, +} + +impl TocEntry { + pub fn new(style: impl Into, level: u8) -> Self { + Self { style: style.into(), level, entry_style: None, page_number: TocPageNumber::After, separator: None } + } +} + +/// Where an entry's page number goes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TocPageNumber { + #[default] + After, + Before, + None, } fn one() -> u8 { @@ -598,6 +622,9 @@ pub struct Toc { pub entries: Vec, #[serde(default)] pub page_numbers: bool, + /// Paragraph style of the title; `None` is the generated "TOC Title" style. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title_style: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index d6ed53d6a..30ef0e875 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -1,9 +1,9 @@ //! Layout → Table of Contents: list the paragraphs of chosen styles with their page names, as a -//! story with "TOC Title" / "TOC Level n" styles (right tab with dot leader). Update Table of -//! Contents regenerates the same story after edits. +//! story with "TOC Title" / "TOC Level n" styles (right tab with dot leader) or the entry styles +//! chosen per style. Update Table of Contents regenerates the same story after edits. use designcraft_compose::{ComposeOptions, caret, compose_story}; -use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry}; +use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry, TocPageNumber}; use designcraft_geom::Rect; use serde_json::{Value, json}; @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Table of Contents…", ["Layout"], None, - "{entries: [{style, level?: 1}] | styles: [names], title?: \"Contents\", pageNumbers?: true, page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", + "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", has_doc, generate ), @@ -44,20 +44,66 @@ fn has_toc(s: &Session) -> std::result::Result<(), String> { } } +const MAX_ENTRIES: usize = 64; +const MAX_NAME: usize = 256; +const MAX_TITLE: usize = 1024; +const MAX_SEPARATOR: usize = 64; + fn entries_param(p: &Value) -> Result> { - if let Some(v) = p.get("entries") { - return serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string())); + let mut entries: Vec = if let Some(v) = p.get("entries") { + if v.as_array().is_some_and(|a| a.len() > MAX_ENTRIES) { + return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} entries"))); + } + serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string()))? + } else if let Some(a) = p.get("styles").and_then(Value::as_array) { + if a.len() > MAX_ENTRIES { + return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} styles"))); + } + a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry::new(s, (i + 1).min(9) as u8))).collect() + } else { + return Err(bad("toc.generate", "give `entries` or `styles`")); + }; + for e in &mut entries { + if !(1..=9).contains(&e.level) { + return Err(bad("toc.generate", format!("level {} of `{}` is not 1–9", e.level, e.style))); + } + if e.style.chars().count() > MAX_NAME || e.entry_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) { + return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); + } + if let Some(sep) = &mut e.separator { + if sep.chars().count() > MAX_SEPARATOR { + return Err(bad("toc.generate", format!("the separator is at most {MAX_SEPARATOR} characters"))); + } + *sep = one_line(sep); + } + // The defaults are stored as `None`, so a definition reads the same however it was made. + if e.entry_style.as_deref() == Some("") { + e.entry_style = None; + } + if e.separator.as_deref() == Some("\t") { + e.separator = None; + } } - if let Some(a) = p.get("styles").and_then(Value::as_array) { - return Ok(a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry { style: s.into(), level: (i + 1).min(9) as u8 })).collect()); + Ok(entries) +} + +/// Paragraph breaks in a title or separator would shift the paragraph styles after them. +fn one_line(s: &str) -> String { + s.chars().map(|c| if matches!(c, '\n' | '\r' | '\u{2029}') { ' ' } else { c }).collect() +} + +fn para_style_exists(d: &Document, name: &str) -> Result<()> { + match d.styles.para(name) { + Some(_) => Ok(()), + None => Err(bad("toc.generate", format!("no paragraph style `{name}`"))), } - Err(bad("toc.generate", "give `entries` or `styles`")) } pub struct Found { pub level: u8, pub text: String, pub page: String, + entry: usize, key: (usize, i64, i64), } @@ -70,7 +116,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option) -> Vec } let cs = compose_story(d, *sid, &ComposeOptions::default()); for (pi, r) in st.para_ranges().into_iter().enumerate() { - let Some(e) = entries.iter().find(|e| e.style == st.paras[pi].style) else { continue }; + let Some((ei, e)) = entries.iter().enumerate().find(|(_, e)| e.style == st.paras[pi].style) else { continue }; let text: String = st.text[r.clone()] .chars() .filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)) @@ -95,6 +141,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option) -> Vec out.push(Found { level: e.level, text, + entry: ei, page: d.page_name(abs), key: (abs, ((b.y0 + baseline) * 10.0) as i64, ((b.x0 + x) * 10.0) as i64), }); @@ -146,22 +193,35 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { d.story(toc.story).and_then(|st| st.frames.first()).and_then(|f| d.item(*f)).map(|i| i.text_area().width()).unwrap_or(300.0) }); ensure_styles(d, levels, width); + for name in toc.entries.iter().filter_map(|e| e.entry_style.as_deref()).chain(toc.title_style.as_deref()) { + para_style_exists(d, name)?; + } let mut text = String::new(); let mut styles = Vec::new(); if !toc.title.is_empty() { text.push_str(&toc.title); - styles.push("TOC Title".to_string()); + styles.push(toc.title_style.clone().unwrap_or_else(|| "TOC Title".into())); } for f in &found { + let Some(e) = toc.entries.get(f.entry) else { continue }; if !text.is_empty() { text.push('\n'); } - text.push_str(&f.text); - if toc.page_numbers { - text.push('\t'); - text.push_str(&f.page); + let sep = e.separator.as_deref().unwrap_or("\t"); + match e.page_number { + TocPageNumber::After if toc.page_numbers => { + text.push_str(&f.text); + text.push_str(sep); + text.push_str(&f.page); + } + TocPageNumber::Before if toc.page_numbers => { + text.push_str(&f.page); + text.push_str(sep); + text.push_str(&f.text); + } + _ => text.push_str(&f.text), } - styles.push(format!("TOC Level {}", f.level.max(1))); + styles.push(e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", f.level.max(1)))); } let st = d.story_mut(toc.story).ok_or(designcraft_doc::DocError::NoStory(toc.story))?; let len = st.len(); @@ -179,13 +239,29 @@ fn generate(s: &mut Session, p: &Value) -> Result { if entries.is_empty() { return Err(bad("toc.generate", "no entries")); } - let title = str_param(p, "title").unwrap_or("Contents").to_string(); + let title = str_param(p, "title").unwrap_or("Contents"); + if title.chars().count() > MAX_TITLE { + return Err(bad("toc.generate", format!("the title is at most {MAX_TITLE} characters"))); + } + let title = one_line(title); + let title_style = str_param(p, "titleStyle").filter(|n| !n.is_empty()).map(str::to_string); + if title_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) { + return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); + } let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true); + let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true); let rect = super::rect_param(p, "rect"); - let page = p.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1; + if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { + return Err(bad("toc.generate", "`rect` must be finite with a positive size")); + } + let page = p.get("page").and_then(Value::as_u64).map_or(Some(0), |n| usize::try_from(n.max(1) - 1).ok()); + let page = page.ok_or_else(|| bad("toc.generate", "no such page"))?; s.edit(|d, sel| { - // Reuse the existing TOC story when there is one; otherwise make a frame. - let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| d.story(*sid).is_some()); + for e in &entries { + para_style_exists(d, &e.style)?; + } + // Replace: regenerate the existing TOC story in place; otherwise make a frame. + let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| replace && d.story(*sid).is_some()); let (story, width) = match existing { Some(sid) => (sid, None), None => { @@ -198,7 +274,7 @@ fn generate(s: &mut Session, p: &Value) -> Result { (sid, w) } }; - let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers }; + let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() }; let n = fill(d, &toc, width)?; d.toc = Some(toc); Ok(json!({"story": story.0, "entries": n})) @@ -237,4 +313,106 @@ mod tests { assert!(text.contains("Thoughts on the Grid"), "{text}"); s.doc().unwrap().doc.check().unwrap(); } + + fn sample_with_toc_page() -> (Session, usize) { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let pages = s.doc().unwrap().doc.page_count(); + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + (s, pages + 1) + } + + fn toc_lines(s: &Session) -> Vec<(String, String)> { + let d = &s.doc().unwrap().doc; + let st = d.story(d.toc.as_ref().unwrap().story).unwrap(); + st.para_ranges().into_iter().zip(&st.paras).map(|(r, f)| (st.text[r].trim_end_matches('\n').to_string(), f.style.clone())).collect() + } + + #[test] + fn toc_entry_style_page_number_position_and_separator_survive_update() { + let (mut s, page) = sample_with_toc_page(); + let basic = designcraft_doc::story::BASIC_PARAGRAPH; + s.execute( + "toc.generate", + &json!({"page": page, "title": "In This Issue", "titleStyle": basic, "entries": [ + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " — "}, + {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, + ]}), + ) + .unwrap(); + let check = |s: &Session, headline: &str| { + let lines = toc_lines(s); + assert_eq!(lines[0], ("In This Issue".to_string(), basic.to_string())); + let cover = lines.iter().find(|(t, _)| t.contains("The Quiet")).unwrap(); + assert!(cover.0.starts_with("1 — The Quiet"), "{cover:?}"); + assert_eq!(cover.1, "TOC Level 1"); + let head = lines.iter().find(|(t, _)| t.contains("on the Grid")).unwrap(); + assert!(head.0.starts_with(headline) && !head.0.contains('\t'), "{head:?}"); + assert_eq!(head.1, basic); + }; + check(&s, "Notes on the Grid"); + let d = &s.doc().unwrap().doc; + let hid = d.stories.iter().find(|(_, st)| st.text.starts_with("Notes on the Grid")).map(|(id, _)| *id).unwrap(); + s.execute("story.replaceRange", &json!({"story": hid.0, "start": 0, "end": 5, "text": "Thoughts"})).unwrap(); + s.execute("toc.update", &json!({})).unwrap(); + check(&s, "Thoughts on the Grid"); + } + + #[test] + fn replace_regenerates_the_toc_in_place_and_off_makes_a_new_frame() { + let (mut s, page) = sample_with_toc_page(); + let stories = |s: &Session| s.doc().unwrap().doc.stories.len(); + let first = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page})).unwrap()["story"].clone(); + let n = stories(&s); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "title": "Inside", "page": page, "replace": true})).unwrap(); + assert_eq!(r["story"], first); + assert_eq!(stories(&s), n, "one table of contents"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(toc_lines(&s)[0].0, "Inside"); + let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page, "replace": false})).unwrap(); + assert_ne!(r["story"], first); + assert_eq!(stories(&s), n + 1); + assert_eq!(s.doc().unwrap().doc.toc.as_ref().unwrap().story.0, r["story"].as_u64().unwrap()); + } + + #[test] + fn hostile_toc_params_are_errors_that_leave_the_document_alone() { + let (mut s, page) = sample_with_toc_page(); + let long = "x".repeat(5000); + let many: Vec = (0..65).map(|_| json!({"style": "Headline"})).collect(); + for p in [ + json!({"entries": [{"style": "Headline", "level": 0}]}), + json!({"entries": [{"style": "Headline", "level": 10}]}), + json!({"entries": [{"style": "Headline", "level": 300}]}), + json!({"entries": [{"style": "Headline", "level": -1}]}), + json!({"entries": [{"style": "Headline", "pageNumber": "sideways"}]}), + json!({"entries": [{"style": "No Such Style"}]}), + json!({"entries": [{"style": "Headline", "entryStyle": "No Such Style"}]}), + json!({"entries": [{"style": "Headline", "separator": long}]}), + json!({"entries": [{"style": long}]}), + json!({"entries": many}), + json!({"entries": "Headline"}), + json!({"entries": []}), + json!({"styles": ["Headline"], "titleStyle": "No Such Style"}), + json!({"styles": ["Headline"], "title": long}), + json!({"styles": ["Headline"], "rect": [100, 100, 50, 50]}), + json!({"styles": ["Headline"], "page": u64::MAX}), + json!([1, 2]), + ] { + let rev = s.doc().unwrap().revision; + assert!(s.execute("toc.generate", &merge(&p, page)).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().revision, rev, "{p}"); + assert!(s.doc().unwrap().doc.toc.is_none()); + } + } + + /// `p` with the TOC page added when it is an object and has none. + fn merge(p: &Value, page: usize) -> Value { + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.entry("page").or_insert(json!(page)); + } + p + } } diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index cc40768ea..143e46699 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -251,3 +251,33 @@ fn named_lists_round_trip_through_idml() { assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps")); assert_eq!(st.paras[0].para.start_at, Some(Some(5))); } + +#[test] +fn table_of_contents_definition_round_trips_through_idml_and_updates() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let page = s.doc().unwrap().doc.page_count() + 1; + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + let basic = designcraft_doc::story::BASIC_PARAGRAPH; + s.execute( + "toc.generate", + &json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [ + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "}, + {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, + ]}), + ) + .unwrap(); + let toc_text = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.story(d.toc.as_ref().unwrap().story).unwrap().text.clone() + }; + let before = s.doc().unwrap().doc.toc.clone().unwrap(); + let text = toc_text(&s); + let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap(); + let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported"); + assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries)); + assert_eq!(toc_text(&s), text); + s.execute("toc.update", &json!({})).unwrap(); + assert_eq!(toc_text(&s), text); +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index d01abe9e7..2d1bf32d8 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -16,6 +16,8 @@ use crate::{DOM_VERSION, MIMETYPE, base64_encode}; const PKG_NS: &str = "http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"; const NO_PARA_ID: &str = "ParagraphStyle/$ID/[No paragraph style]"; +const TOC_STYLE_ID: &str = "TOCStyle/Table of Contents"; +const TOC_STYLE_NAME: &str = "Table of Contents"; const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]"; /// Export options. @@ -1049,9 +1051,46 @@ impl<'a> Ex<'a> { let mut ro = El::new("RootObjectStyleGroup").attr("Self", self.fresh()); og.emit(&mut ro, "ObjectStyleGroup", ""); root.push(ro); + if let Some(toc) = d.toc.as_ref().filter(|t| d.story(t.story).is_some()) { + root.push(self.toc_style_el(toc)); + } document(&root) } + /// The document's table of contents as a TOC style; its story names it in `AppliedTOCStyle`. + fn toc_style_el(&mut self, toc: &designcraft_doc::Toc) -> El { + let para = |n: &str| names::style_self("ParagraphStyle", PARA_BUILTINS, n); + let mut el = El::new("TOCStyle") + .attr("Self", TOC_STYLE_ID) + .attr("Name", TOC_STYLE_NAME) + .attr("Title", &toc.title) + .attr("TitleStyle", para(toc.title_style.as_deref().unwrap_or("TOC Title"))) + .attr("RunIn", "false") + .attr("IncludeHidden", "false") + .attr("IncludeBookDocuments", "false") + .attr("CreatePdfBookmarks", "false"); + for e in &toc.entries { + let format = e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", e.level)); + let position = match e.page_number { + _ if !toc.page_numbers => "NoPageNumber", + designcraft_doc::TocPageNumber::After => "AfterEntry", + designcraft_doc::TocPageNumber::Before => "BeforeEntry", + designcraft_doc::TocPageNumber::None => "NoPageNumber", + }; + el.push( + El::new("TOCStyleEntry") + .attr("Self", self.fresh()) + .attr("Name", names::style_name_out(PARA_BUILTINS, &e.style)) + .attr("FormatStyle", para(&format)) + .attr("Level", e.level) + .attr("PageNumberPosition", position) + .attr("Separator", names::toc_separator_out(e.separator.as_deref().unwrap_or("\t"))) + .attr("Sort", "false"), + ); + } + el + } + fn char_attrs(&mut self, el: &mut El, props: &mut Vec, a: &CharAttrs) { if let Some(v) = a.leading_aki { el.set("LeadingAki", num(v.unwrap_or(-1.0))); @@ -1923,7 +1962,12 @@ impl<'a> Ex<'a> { // ---------- stories ---------- fn story_el(&mut self, s: &Story) -> El { - let mut el = El::new("Story").attr("Self", uid(s.id.0)).attr("AppliedTOCStyle", "n").attr("TrackChanges", "false").attr("StoryTitle", "$ID/"); + let toc = self.d.toc.as_ref().is_some_and(|t| t.story == s.id); + let mut el = El::new("Story") + .attr("Self", uid(s.id.0)) + .attr("AppliedTOCStyle", if toc { TOC_STYLE_ID } else { "n" }) + .attr("TrackChanges", "false") + .attr("StoryTitle", "$ID/"); el.push( El::new("StoryPreference") .attr("OpticalMarginAlignment", "false") diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be7..c7fe2aec6 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -209,6 +209,7 @@ struct Importer<'r> { /// Index topic Self → topic path and per-level sort keys; topic cross-references. index_topics: HashMap, index_xrefs: Vec, + toc: Option, } /// IDML stores a sort key on each topic, rather than on its page references. @@ -312,6 +313,7 @@ impl<'r> Importer<'r> { xref_formats: designcraft_doc::xref::default_formats(), index_topics: HashMap::new(), index_xrefs: Vec::new(), + toc: None, } } @@ -485,6 +487,7 @@ impl<'r> Importer<'r> { let story = self.story(id, e); self.stories.insert(id, story); } + self.toc = self.toc_of(top); // Topic cross-references (See / See also) become markers at the start of the first story // with text. if !self.index_xrefs.is_empty() @@ -1140,6 +1143,44 @@ impl<'r> Importer<'r> { self.footnote_options = o; } + /// The table of contents: the TOC style a story is generated from (`AppliedTOCStyle`), with + /// its entries. Styles the document doesn't have fall back to the generated TOC styles. + fn toc_of(&self, top: &[El]) -> Option { + use designcraft_doc::{TocEntry, TocPageNumber}; + let (ts, story) = top.iter().filter(|e| e.local() == "TOCStyle").find_map(|ts| { + let id = ts.get("Self")?; + let story = top.iter().find(|e| e.local() == "Story" && e.get("AppliedTOCStyle") == Some(id))?; + Some((ts, *self.story_ids.get(story.get("Self")?)?)) + })?; + let known = |r: Option<&str>| r.map(|r| self.para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); + let entries: Vec = ts + .elements() + .filter(|e| e.local() == "TOCStyleEntry") + .filter_map(|e| { + let style = names::style_name_in(PARA_BUILTINS, e.get("Name")?); + if style.is_empty() || style.chars().count() > 256 { + return None; + } + let level = e.get("Level").and_then(|l| l.trim().parse::().ok()).unwrap_or(1).clamp(1, 9); + let page_number = match e.get("PageNumberPosition") { + Some("BeforeEntry") => TocPageNumber::Before, + Some("NoPageNumber") => TocPageNumber::None, + _ => TocPageNumber::After, + }; + let separator = e.get("Separator").map(crate::names::toc_separator_in).filter(|s| s != "\t" && s.chars().count() <= 64); + let entry_style = known(e.get("FormatStyle")).filter(|n| *n != format!("TOC Level {level}")); + Some(TocEntry { style, level, entry_style, page_number, separator }) + }) + .take(64) + .collect(); + if entries.is_empty() { + return None; + } + let title: String = ts.get("Title").unwrap_or("").chars().filter(|c| !matches!(c, '\n' | '\r' | '\u{2029}')).take(1024).collect(); + let title_style = known(ts.get("TitleStyle")).filter(|n| n != "TOC Title"); + Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style }) + } + fn para_style_ref(&self, r: &str) -> String { self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/")))) } @@ -2434,7 +2475,7 @@ impl<'r> Importer<'r> { bookmarks: vec![], user_words: vec![], hyphenation_exceptions: vec![], - toc: None, + toc: self.toc.take(), text_variables: designcraft_doc::vars::defaults(), footnote_options: std::mem::take(&mut self.footnote_options), xref_formats: std::mem::take(&mut self.xref_formats), diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 1df0f56d5..3d863ef30 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -689,3 +689,29 @@ pub fn digits_in(s: &str) -> Option { use designcraft_doc::Digits; [Digits::Default, Digits::Arabic, Digits::Hindi, Digits::Farsi, Digits::Native].into_iter().find(|d| digits_out(*d) == s) } + +/// A TOC separator in IDML's special-character notation: `^t` is a tab, `^^` a caret. +pub fn toc_separator_out(s: &str) -> String { + s.replace('^', "^^").replace('\t', "^t") +} +/// IDML TOC separator → text (`^t` and `^y`, the right indent tab, are tabs; other codes stay). +pub fn toc_separator_in(s: &str) -> String { + let mut out = String::new(); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '^' { + out.push(c); + continue; + } + match it.next() { + Some('t' | 'y') => out.push('\t'), + Some('^') => out.push('^'), + Some(o) => { + out.push('^'); + out.push(o); + } + None => out.push('^'), + } + } + out +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af1..d8178f363 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2,7 +2,7 @@ //! (`ui.dialog.set {field, value}`) and confirm them (`ui.dialog.confirm`) like a user would. use designcraft_geom::{Unit, format_measure, units::parse_measure}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::DesignApp; @@ -1657,6 +1657,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), + TOC => toc_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { @@ -2188,6 +2189,15 @@ pub fn confirm(app: &mut DesignApp) -> Result { } r } + TOC => { + let r = app.run("toc.generate", toc_params(app, &d)); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } id if id.starts_with("cmd:") => { let cid = &id[4..]; let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params); @@ -3580,6 +3590,223 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +const TOC: &str = "cmd:toc.generate"; + +/// One included style of the Table of Contents dialog, as `toc.generate` takes it, with the +/// separator in the dialog's notation (`^t` a tab). +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", default)] +struct TocRow { + style: String, + level: u8, + entry_style: String, + page_number: String, + separator: String, +} + +fn toc_separator_shown(s: &str) -> String { + s.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>") +} + +fn toc_separator_typed(s: &str) -> String { + s.replace("^t", "\t").replace("^m", "\u{2003}").replace("^>", "\u{2002}") +} + +fn toc_rows(d: &Dialog) -> Vec { + d.fields.get("included").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default() +} + +/// Layout › Table of Contents…: prefilled from the document's table of contents when it has one. +pub fn open_toc(app: &mut DesignApp) { + let Some(st) = app.session.active() else { return }; + let d = &st.doc; + let styles: Vec<&str> = d.styles.paragraph.iter().map(|p| p.name.as_str()).filter(|n| *n != designcraft_doc::NO_PARA_STYLE).collect(); + let toc = d.toc.as_ref().filter(|t| d.story(t.story).is_some()); + let rows: Vec = toc + .map(|t| { + t.entries + .iter() + .map(|e| TocRow { + style: e.style.clone(), + level: e.level, + entry_style: e.entry_style.clone().unwrap_or_default(), + page_number: match (t.page_numbers, e.page_number) { + (false, _) | (_, designcraft_doc::TocPageNumber::None) => "none", + (_, designcraft_doc::TocPageNumber::Before) => "before", + _ => "after", + } + .into(), + separator: toc_separator_shown(e.separator.as_deref().unwrap_or("\t")), + }) + .collect() + }) + .unwrap_or_default(); + let fields = json!({ + "title": toc.map_or("Contents", |t| t.title.as_str()), + "titleStyle": toc.and_then(|t| t.title_style.clone()).unwrap_or_default(), + "included": rows, + "selected": 0, + "styles": styles, + "hasToc": toc.is_some(), + "replace": toc.is_some(), + }); + app.ui.dialog = Some(Dialog::new(TOC, fields)); +} + +/// The dialog's fields as `toc.generate` params. A new frame goes on the page in view. +fn toc_params(app: &DesignApp, d: &Dialog) -> Value { + let entries: Vec = toc_rows(d) + .iter() + .map(|r| { + let mut e = json!({"style": r.style, "level": r.level, "separator": toc_separator_typed(&r.separator)}); + if !r.entry_style.is_empty() { + e["entryStyle"] = json!(r.entry_style); + } + if !r.page_number.is_empty() { + e["pageNumber"] = json!(r.page_number); + } + e + }) + .collect(); + let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace")}); + let title_style = d.s("titleStyle"); + if !title_style.is_empty() { + p["titleStyle"] = json!(title_style); + } + if let Some(page) = crate::canvas::current_page(app) { + p["page"] = json!(page + 1); + } + p +} + +/// A paragraph style menu whose first choice, stored as "", is the generated style `auto`. +fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String) -> bool { + let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() }; + let mut changed = false; + egui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| { + if ui.selectable_label(cur.is_empty(), auto).clicked() { + cur.clear(); + changed = true; + } + for n in styles.iter().filter(|n| *n != auto) { + if ui.selectable_label(cur == n, n.as_str()).clicked() { + *cur = n.clone(); + changed = true; + } + } + }); + changed +} + +/// Layout › Table of Contents: the title and its style, the paragraph styles to include (Add / +/// Remove between the two lists) and, for the selected one, its entry style, page number +/// position, separator and level. +fn toc_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 styles: Vec = d.fields.get("styles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut rows = toc_rows(d); + let mut sel = d.n("selected").map_or(0, |n| n.max(0.0) as usize); + let mut other = d.s("other"); + let mut title_style = d.s("titleStyle"); + egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Title:")); + text_field(ui, d, "title", 200.0); + crate::rtl::label(ui, tr("Style:")); + toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style); + ui.end_row(); + }); + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + let others: Vec<&String> = styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)).collect(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + crate::rtl::label(ui, tr("Include Paragraph Styles:")); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_width(200.0); + egui::ScrollArea::vertical().id_salt("toc_included").max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { + for (i, r) in rows.iter().enumerate() { + let indent = " ".repeat(usize::from(r.level.saturating_sub(1))); + if ui.selectable_label(i == sel, format!("{indent}{}", r.style)).clicked() { + sel = i; + } + } + }); + }); + }); + ui.vertical(|ui| { + ui.add_space(40.0); + if ui.button(tr("« Add")).clicked() && others.iter().any(|n| **n == other) { + let level = u8::try_from(rows.len() + 1).unwrap_or(9).min(9); + rows.push(TocRow { style: other.clone(), level, page_number: "after".into(), separator: "^t".into(), ..Default::default() }); + sel = rows.len() - 1; + other.clear(); + } + if ui.button(tr("Remove »")).clicked() && sel < rows.len() { + other = rows.remove(sel).style; + sel = sel.min(rows.len().saturating_sub(1)); + } + }); + ui.vertical(|ui| { + crate::rtl::label(ui, tr("Other Styles:")); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_width(200.0); + egui::ScrollArea::vertical().id_salt("toc_other").max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { + for n in styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)) { + if ui.selectable_label(*n == other, n.as_str()).clicked() { + other = n.clone(); + } + } + }); + }); + }); + }); + if let Some(r) = rows.get_mut(sel) { + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(format!("{} {}", tr("Style:"), r.style)).font(semibold(12.0))); + egui::Grid::new("toc_entry").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Entry Style:")); + let auto = format!("TOC Level {}", r.level.clamp(1, 9)); + toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style); + ui.end_row(); + crate::rtl::label(ui, tr("Page Number:")); + let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")]; + let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l); + egui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(r.page_number == v, tr(l)).clicked() { + r.page_number = v.into(); + } + } + }); + ui.end_row(); + crate::rtl::label(ui, tr("Between Entry and Number:")); + ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).horizontal_align(egui::Align::Min)); + ui.end_row(); + crate::rtl::label(ui, tr("Level:")); + egui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| { + for l in 1..=9u8 { + if ui.selectable_label(r.level == l, l.to_string()).clicked() { + r.level = l; + } + } + }); + ui.end_row(); + }); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(tr("Options")).font(semibold(12.0))); + let has_toc = d.b("hasToc"); + ui.add_enabled_ui(has_toc, |ui| check(ui, d, "replace", &tr("Replace Existing Table of Contents"))); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } + d.fields.insert("included".into(), json!(rows)); + d.fields.insert("selected".into(), json!(sel)); + d.fields.insert("other".into(), json!(other)); + d.fields.insert("titleStyle".into(), json!(title_style)); +} /// The params a previewing dialog shows on the page (its interaction is open while it is set). const PREVIEWED: &str = "_previewed"; @@ -3954,6 +4181,44 @@ mod tests { assert_eq!(d.s("status"), "Invalid value: up"); } + #[test] + fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + crate::menus::activate(&mut app, "toc.generate", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(TOC)); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in + ["Table of Contents", "Title:", "Include Paragraph Styles:", "Other Styles:", "« Add", "Remove »", "Replace Existing Table of Contents"] + { + visible_label(&labels, screen, expected); + } + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Headline").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "« Add").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Entry Style:", "TOC Level 1", "After Entry", "Between Entry and Number:", "Level:"] { + visible_label(&labels, screen, expected); + } + let undo = app.session.doc().unwrap().history.undo.len(); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); + assert!(app.ui.dialog.is_none()); + let d = &app.session.doc().unwrap().doc; + let toc = d.toc.clone().unwrap(); + assert_eq!(toc.entries, vec![designcraft_doc::TocEntry::new("Headline", 1)]); + let text = &d.story(toc.story).unwrap().text; + assert!(text.starts_with("Contents\n") && text.contains("\nNotes on the Grid\t"), "{text}"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // Reopened, the dialog shows the table of contents and replaces it. + crate::menus::activate(&mut app, "toc.generate", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.b("replace")); + assert_eq!(toc_rows(d).iter().map(|r| r.style.as_str()).collect::>(), ["Headline"]); + } + 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/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..4f33f9bbb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1888,6 +1888,10 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { crate::dialogs::open_document_setup(app); return; } + if params.is_null() && id == "toc.generate" { + crate::dialogs::open_toc(app); + return; + } if params.is_null() && id == "file.print" { crate::dialogs::open_print(app); return; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..625cfb4a5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -428,7 +428,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu. - Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `` in IDML. - Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. -- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. +- Table of contents (Layout ▸ Table of Contents dialog: included styles, per-style entry style, page number position, separator and level, title style, Replace Existing; generate/update, dot leaders; the definition round-trips through IDML as a TOC style) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. - Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather. - ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG. - MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input. From 923479bd053bab3f99d47c41830983748d16aad1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 20:00:37 +0000 Subject: [PATCH 106/200] 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 1bddba0e7578b79a8eba2db7dbaba2175dfdc450 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:07:21 +0200 Subject: [PATCH 107/200] Compose: cross-reference index no longer rebuilds itself (#851) The cross-reference index composed its destination stories through the cache. One with a running header asked for the running-header index, which composed the story holding the cross-reference, which asked for the cross-reference index again: the stack overflowed and the app aborted. Destination stories with a running header are now composed without it, like those with cross-references already were. --- crates/compose/src/xref.rs | 8 +++++--- crates/engine/src/cmd/xref.rs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/xref.rs b/crates/compose/src/xref.rs index 4bce48d27..b33714203 100644 --- a/crates/compose/src/xref.rs +++ b/crates/compose/src/xref.rs @@ -19,15 +19,17 @@ pub fn has_xrefs(st: &Story) -> bool { } impl XrefIndex { - /// `get` composes a story (normally through the cache). Stories that themselves hold - /// cross-references are composed without resolving them (their anchors' pages hardly move). + /// `get` composes a story (normally through the cache). Stories that hold cross-references or + /// running headers are composed without resolving them (their anchors' pages hardly move): + /// through the cache they would build this index again, directly or by way of the running-header index. pub fn build(doc: &Document, get: &dyn Fn(StoryId) -> Arc) -> XrefIndex { let mut pages = HashMap::new(); for (sid, st) in &doc.stories { if st.anchors.is_empty() { continue; } - let cs = if has_xrefs(st) { Arc::new(crate::compose_story(doc, *sid, &Default::default())) } else { get(*sid) }; + let indexed = has_xrefs(st) || crate::vars::has_running(doc, st); + let cs = if indexed { Arc::new(crate::compose_story(doc, *sid, &Default::default())) } else { get(*sid) }; for a in &st.anchors { let Some(pos) = st.anchor_pos(a.id) else { continue }; if let Some((page, ..)) = crate::vars::place(doc, &cs, pos) { diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs index b0b97ebd2..54e5dd6b6 100644 --- a/crates/engine/src/cmd/xref.rs +++ b/crates/engine/src/cmd/xref.rs @@ -214,4 +214,23 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)"); } + + #[test] + fn cross_reference_into_a_story_with_a_running_header_composes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let head = s.execute("frame.create", &json!({"rect": [72, 72, 400, 120], "content": "text", "text": "Chapter"})).unwrap()["story"].clone(); + let body = s.execute("frame.create", &json!({"rect": [72, 200, 400, 300], "content": "text", "text": "See"})).unwrap()["story"].clone(); + s.execute("text.select", &json!({"story": head, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Running Header"})).unwrap(); + s.execute("text.select", &json!({"story": body, "anchor": 3, "focus": 3})).unwrap(); + s.execute("xref.insert", &json!({"story": head, "para": 0})).unwrap(); + // The cross-reference index and the running-header index used to build each other without end. + assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "\u{201C}Chapter\u{201D} on page 1"); + // The header shows the body's paragraph in front of its own text. + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, StoryId(head.as_u64().unwrap()), None); + let glyphs: usize = cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum(); + assert_eq!(glyphs, "SeeChapter".len()); + } } From 0fd627aa44b268d935f04e1b094d46ba84815865 Mon Sep 17 00:00:00 2001 From: LabDennis <341377186+LabDennis@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:12:09 +0200 Subject: [PATCH 108/200] perf(assets): index duplicate candidates by content (#727) --- crates/doc/src/assets.rs | 64 ++++++++++++++++++++++++++++++++++++-- crates/format/src/lib.rs | 33 ++++++++++++++++++-- docs/file-format-parity.md | 5 +-- 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 24ba5c243..cff196fc0 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -5,6 +5,7 @@ //! shown); each placement's frame data (transform, crop, fitting) lives on its [`Graphic`]. use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::hash::BuildHasher; use std::sync::Arc; use crate::story::Story; @@ -246,10 +247,19 @@ impl Document { if !self.asset_refs(&mut |_| {}) { return 0; } - let mut reps: HashMap<(usize, &str, u32), Vec<&Arc>> = HashMap::new(); + let fingerprint = std::collections::hash_map::RandomState::new(); + let mut hashes = HashMap::new(); + let mut reps: HashMap<_, Vec<&Arc>> = HashMap::new(); let mut map = HashMap::new(); for a in self.assets.values() { - let bucket = reps.entry((a.data.len(), a.mime.as_str(), a.page)).or_default(); + // Empty, unlinked assets never compare equal. Don't build a quadratic bucket + // of them, and hash a shared immutable buffer only once per compaction. + if a.data.is_empty() && a.link.is_none() { + continue; + } + let hash = *hashes.entry(Arc::as_ptr(&a.data)).or_insert_with(|| fingerprint.hash_one(a.data.as_slice())); + let bucket = reps.entry((hash, a.mime.as_str(), a.page, a.link.as_deref())).or_default(); + // A fingerprint only selects candidates; equality still checks the actual file. match bucket.iter().find(|r| r.same_file(a)) { Some(r) => { map.insert(a.id, r.id); @@ -280,6 +290,56 @@ mod tests { Asset { id: AssetId(id), name: "p.png".into(), mime: "image/png".into(), link: None, data: Arc::new(data.to_vec()), pixels: None, page: 0 } } + #[test] + fn equal_size_assets_keep_distinct_contents_and_merge_exact_copies() { + let mut d = Document::new(&Default::default()); + for id in 1..=256u64 { + let mut bytes = vec![42; 1024]; + bytes[1016..].copy_from_slice(&id.to_le_bytes()); + d.assets.insert(AssetId(id), Arc::new(asset(id, &bytes))); + d.assets.insert(AssetId(id + 256), Arc::new(asset(id + 256, &bytes))); + } + assert_eq!(d.merge_duplicate_assets(), 256); + assert_eq!(d.assets.len(), 256); + for id in 1..=256u64 { + assert_eq!(d.assets[&AssetId(id)].data[1016..], id.to_le_bytes()); + } + } + + #[test] + fn compaction_preserves_asset_identity_and_missing_data_rules() { + let mut d = Document::new(&Default::default()); + let mut cases = vec![asset(1, b"same"), asset(2, b"same"), asset(3, b"same"), asset(4, b"same")]; + cases[1].mime = "application/pdf".into(); + cases[2].page = 1; + cases[3].link = Some("/linked.png".into()); + cases.extend([asset(5, b""), asset(6, b""), asset(7, b""), asset(8, b"")]); + cases[6].link = Some("/missing.png".into()); + cases[7].link = Some("/missing.png".into()); + for a in cases { + d.assets.insert(a.id, Arc::new(a)); + } + assert_eq!(d.merge_duplicate_assets(), 1); + assert_eq!(d.assets.keys().copied().collect::>(), (1..=7).map(AssetId).collect::>()); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + #[ignore = "manual bounded timing comparison; no timing assertion"] + fn equal_size_assets_timing() { + for count in [500, 1000, 2000] { + let mut d = Document::new(&Default::default()); + for id in 1..=count as u64 { + let mut bytes = vec![42; 8192]; + bytes[8184..].copy_from_slice(&id.to_le_bytes()); + d.assets.insert(AssetId(id), Arc::new(asset(id, &bytes))); + } + let start = std::time::Instant::now(); + assert_eq!(d.merge_duplicate_assets(), 0); + eprintln!("{count} distinct 8 KiB assets: {:?}", start.elapsed()); + } + } + fn graphic_item(id: u64, asset: u64, crop: f64) -> Item { let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); it.content = Content::Graphic(Graphic { diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 408bb3ccc..c1b6d805a 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -11,6 +11,7 @@ #![forbid(unsafe_code)] use std::collections::{HashMap, HashSet}; +use std::hash::BuildHasher; use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -107,12 +108,13 @@ fn load_zip(bytes: &[u8]) -> Result { let names: Vec = z.file_names().map(str::to_string).collect(); // Files saved with one copy per placement: identical copies share one buffer as they are // read, so memory holds each distinct file once. - let mut loaded: HashMap>>> = HashMap::new(); + let fingerprint = std::collections::hash_map::RandomState::new(); + let mut loaded: HashMap>>> = HashMap::new(); 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))) { - let same = loaded.entry(data.len()).or_default(); + 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(), None => { @@ -338,6 +340,33 @@ mod tests { } } + #[test] + fn equal_size_archive_assets_preserve_content_and_share_only_identical_buffers() { + let mut d = Document::new(&NewDocument::default()); + let mut pairs = Vec::new(); + for n in 0..128u64 { + let mut bytes = vec![42; 1024]; + bytes[1016..].copy_from_slice(&n.to_le_bytes()); + let a = png_asset(&mut d, &bytes); + let b = png_asset(&mut d, &bytes); + // Distinct links must remain independently relinkable even when their bytes match. + Arc::make_mut(d.assets.get_mut(&a).unwrap()).link = Some(format!("/a/{n}.png")); + Arc::make_mut(d.assets.get_mut(&b).unwrap()).link = Some(format!("/b/{n}.png")); + place(&mut d, a, 1.0); + place(&mut d, b, 2.0); + pairs.push((a, b)); + } + let back = load(&save_uncompacted(&d)).unwrap(); + assert_eq!(back.assets.len(), 256); + for (n, (a, b)) in pairs.into_iter().enumerate() { + assert_eq!(back.assets[&a].data[1016..], (n as u64).to_le_bytes()); + assert!(Arc::ptr_eq(&back.assets[&a].data, &back.assets[&b].data)); + assert_eq!(back.assets[&a].link, d.assets[&a].link); + assert_eq!(back.assets[&b].link, d.assets[&b].link); + } + assert_eq!(back.spreads, d.spreads); + } + #[test] fn older_files_with_one_asset_per_placement_open_with_one() { let mut d = Document::new(&NewDocument::default()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fc8fbc3a5..06f59de94 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6) Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`, @@ -22,7 +22,7 @@ score below). | InDesign document | `.indd` | read/write (native) | ✗ (#21; converters proposed in #98, #114) | ✗ | — | 30% | | InDesign template | `.indt` | read/write | ✗ | ✗ | — | 2% | | InDesign Markup | `.idml` | read/write | ✓ | ✓ | Synthetic fixtures in `crates/idml/src/tests.rs` (49 tests); exported files verified by opening in InDesign 2026 by hand; indd-utils' 98-document run imports 811 spreads / 2,866 stories / 5,812 items. Open: #193, #338, #339, #340, #211. No real-file corpus test | 20% | -| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests | — | +| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests. Asset loading and compaction index by content hash, then confirm byte equality; equal-sized distinct files no longer all share one search bucket (#727). Separate links, MIME types and PDF pages retain their identity | — | | InDesign book | `.indb` | read/write | ✗ (own `.dcbook`) | ✗ | Books are shallow (see gaps.md) | 2% | | InDesign library | `.indl`, `.inlx` | read/write | ✗ (own `.dclib`) | ✗ | | 1% | | Snippet | `.idms` | read/write | ✗ (own snippets) | ✗ | | 1% | @@ -107,4 +107,5 @@ for real work** (estimated). INDD alone moves this dimension by up to 30 points. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Asset loading and compaction use content fingerprints with exact equality checks; tests cover equal-sized distinct buffers, shared copies, independent links and missing data (#727) | | 2026-10-10 | major | Created from InDesign 2026's declared document types and filters, `plan/indesign/08-file-formats.md` and the code | From 2d07bc5295ef71ec3dffbb6e52f67e0833291cc4 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:28:38 -0400 Subject: [PATCH 109/200] fix: refresh canvas when switching parent editing --- ROADMAP.md | 4 +- crates/ui-egui/examples/ui_shot.rs | 1 + crates/ui-egui/src/canvas.rs | 18 +++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/parent_canvas_tests.rs | 104 ++++++++++++++++++++++ 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/parent_canvas_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..2499c222d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **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-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -127,6 +127,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. + Newest first. - **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. diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index c0bf38943..c241df135 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -31,6 +31,7 @@ fn main() { } let mut harness = egui_kittest::Harness::builder() .with_size(egui::vec2(1440.0, 900.0)) + .with_step_dt(1.0 / 60.0) .with_pixels_per_point(2.0) .with_max_steps(1_000_000) .wgpu() diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..0289ad9f0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -372,12 +372,14 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let Some(st) = app.session.active() else { return }; let ppp = ctx.pixels_per_point() as f64; let doc_key = (st.uid, std::sync::Arc::as_ptr(&st.doc) as u64, st.revision, preview); + let editing_parents = st.editing_parents; // Overscan: 25% of the viewport on every side, within the GPU's maximum texture size. let max_side = ctx.input(|i| i.max_texture_side) as f32 / ppp as f32; let (ow, oh) = ((rect.width() * 0.25).min((max_side - rect.width()) / 2.0).max(0.0), (rect.height() * 0.25).min((max_side - rect.height()) / 2.0).max(0.0)); let want = |origin: Point| crate::Shown { doc: doc_key, + editing_parents, origin: Point::new(origin.x - ow as f64 / xf.zoom, origin.y - oh as f64 / xf.zoom), zoom: xf.zoom, size: (rect.width() + 2.0 * ow, rect.height() + 2.0 * oh), @@ -385,7 +387,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let target = want(xf.origin); // Does the current texture cover the viewport at this zoom and document state? let covers = |s: &crate::Shown| { - s.doc == doc_key && (s.zoom - xf.zoom).abs() < 1e-9 && { + s.doc == doc_key && s.editing_parents == editing_parents && (s.zoom - xf.zoom).abs() < 1e-9 && { let x0 = (xf.origin.x - s.origin.x) * s.zoom; let y0 = (xf.origin.y - s.origin.y) * s.zoom; x0 >= -0.5 && y0 >= -0.5 && x0 + rect.width() as f64 <= s.size.0 as f64 + 0.5 && y0 + rect.height() as f64 <= s.size.1 as f64 + 0.5 @@ -407,6 +409,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, return; } let doc_changed = app.canvas.shown.is_none_or(|s| s.doc != doc_key); + let mode_changed = app.canvas.shown.is_some_and(|s| s.editing_parents != editing_parents); let zoom_same = app.canvas.shown.is_some_and(|s| (s.zoom - xf.zoom).abs() < 1e-9); let job = |app: &DesignApp| { let st = app.session.active()?; @@ -449,7 +452,9 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, if doc_changed && zoom_same && patch_texture(app, layout, ppp, doc_key, preview) { return; } - if !has_worker || app.canvas.shown.is_none() || (doc_changed && zoom_same) { + // A mode switch changes every spread's meaning, even if the document has no damage. + // Show the new canvas immediately, including when fitting it changes the zoom. + if !has_worker || app.canvas.shown.is_none() || mode_changed || (doc_changed && zoom_same) { let t0 = crate::now_ms(); let Some((doc, placed, w, h, view, opts)) = job(app) else { return }; let img = app.canvas.renderer.render(&doc, &app.session.cache, &placed, w, h, view, &opts); @@ -457,6 +462,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, app.canvas.shown = Some(target); app.canvas.shown_doc = Some(doc); app.canvas.pending = None; + app.canvas.pending_doc = None; app.perf.render_ms = crate::now_ms() - t0; return; } @@ -464,7 +470,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, { // Already rendering this view? if app.canvas.pending.is_some_and(|(_, p)| { - p.doc == doc_key && (p.zoom - xf.zoom).abs() < 1e-9 && { + p.doc == doc_key && p.editing_parents == editing_parents && (p.zoom - xf.zoom).abs() < 1e-9 && { let x0 = (xf.origin.x - p.origin.x) * p.zoom; let y0 = (xf.origin.y - p.origin.y) * p.zoom; x0 >= 0.0 && y0 >= 0.0 && x0 + rect.width() as f64 <= p.size.0 as f64 && y0 + rect.height() as f64 <= p.size.1 as f64 @@ -487,7 +493,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, /// Returns false when a full render is needed (view moved, too much changed, or damage unknown). fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: (u64, u64, u64, bool), preview: bool) -> bool { let (Some(sh), Some(old), Some(st)) = (app.canvas.shown, app.canvas.shown_doc.clone(), app.session.active()) else { return false }; - if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || app.canvas.texture.is_none() { + if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || sh.editing_parents != st.editing_parents || app.canvas.texture.is_none() { return false; } let new = st.doc.clone(); @@ -1771,6 +1777,10 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec true } +#[cfg(test)] +#[path = "parent_canvas_tests.rs"] +mod parent_canvas_tests; + #[cfg(test)] mod tests { use designcraft_geom::Unit; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..6e7650b92 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -410,6 +410,8 @@ impl Default for View { pub struct Shown { /// (doc uid, doc pointer, revision, preview). pub doc: (u64, u64, u64, bool), + /// Parent and document spreads have different canvas placement and content. + pub editing_parents: bool, /// Canvas point at the texture's top-left, zoom, and texture size in screen points. pub origin: Point, pub zoom: f64, diff --git a/crates/ui-egui/src/parent_canvas_tests.rs b/crates/ui-egui/src/parent_canvas_tests.rs new file mode 100644 index 000000000..b3197a7e0 --- /dev/null +++ b/crates/ui-egui/src/parent_canvas_tests.rs @@ -0,0 +1,104 @@ +use super::*; + +fn app_with_document_object() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "pages": 1, "facingPages": true, "margins": 4})).unwrap(); + app.run("frame.create", json!({"rect": [24, 24, 40, 40], "content": "unassigned"})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]"})).unwrap(); + // Exercise the production texture cache without starting a background thread. + app.canvas.worker_started = true; + app +} + +fn render(app: &mut DesignApp, ctx: &egui::Context) -> egui::epaint::ImageDelta { + render_at_zoom(app, ctx, 1.0) +} + +fn render_at_zoom(app: &mut DesignApp, ctx: &egui::Context, zoom: f64) -> egui::epaint::ImageDelta { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(128.0, 64.0)); + let view = View { zoom, origin: Point::new(-64.0, 0.0), fitted: true, rotation: 0 }; + let mut output = ctx.run_ui(Default::default(), |ui| { + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + render_texture(app, ui.ctx(), rect, &Xf::new(rect, &view), &layout, false); + }); + let id = app.canvas.texture.as_ref().unwrap().id(); + let deltas = output.textures_delta.set.remove(&id); + output.textures_delta.clear(); + let mut deltas = deltas.expect("canvas pixels must update"); + assert_eq!(deltas.len(), 1, "one canvas render should produce one texture update"); + deltas.pop().unwrap() +} + +fn pixel(delta: &egui::epaint::ImageDelta, shown: crate::Shown, point: Point) -> Color32 { + let egui::ImageData::Color(image) = &delta.image; + let offset = delta.pos.unwrap_or([0, 0]); + let x = ((point.x - shown.origin.x) * shown.zoom).round() as usize - offset[0]; + let y = ((point.y - shown.origin.y) * shown.zoom).round() as usize - offset[1]; + image[(x, y)] +} + +#[test] +fn switching_parent_editing_repaints_pages_and_document_objects() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + let left_page = Point::new(-32.0, 32.0); + let document_object = Point::new(32.0, 32.0); + let before = render(&mut app, &ctx); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&before, shown, left_page), Color32::TRANSPARENT); + let document_color = pixel(&before, shown, document_object); + assert_ne!(document_color, Color32::WHITE); + assert_eq!(document_color.a(), 255); + + let doc = app.session.active().unwrap().doc.clone(); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &app.session.active().unwrap().doc), "switching canvas mode does not edit the document"); + let parent = render(&mut app, &ctx); + assert!(parent.pos.is_none(), "the parent canvas needs a complete repaint"); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&parent, shown, left_page), Color32::WHITE, "the parent's left page must become visible"); + assert_eq!(pixel(&parent, shown, document_object), Color32::WHITE, "document-only objects must disappear in parent editing"); + + app.run("layout.parents.edit", json!({"on": false})).unwrap(); + let restored = render(&mut app, &ctx); + assert!(restored.pos.is_none()); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&restored, shown, left_page), Color32::TRANSPARENT); + assert_eq!(pixel(&restored, shown, document_object), document_color); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn switching_parent_editing_with_a_new_zoom_repaints_before_the_worker() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + app.canvas.worker = Some(crate::render_worker::Worker::spawn(ctx.clone()).expect("render worker must start")); + let before = render(&mut app, &ctx); + assert_ne!(pixel(&before, app.canvas.shown.unwrap(), Point::new(32.0, 32.0)), Color32::WHITE); + + // Fitting the parent spread can change the zoom as well as the canvas mode. + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + let parent = render_at_zoom(&mut app, &ctx, 0.75); + assert!(parent.pos.is_none(), "a mode switch must upload the new canvas in this frame"); + assert!(app.canvas.pending.is_none(), "parent pages must not wait behind an asynchronous zoom render"); + let shown = app.canvas.shown.unwrap(); + assert_eq!(shown.zoom, 0.75); + assert_eq!(pixel(&parent, shown, Point::new(-32.0, 32.0)), Color32::WHITE); + assert_eq!(pixel(&parent, shown, Point::new(32.0, 32.0)), Color32::WHITE); +} + +#[test] +fn small_edits_in_the_same_canvas_mode_still_repaint_partially() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + let before = render(&mut app, &ctx); + let point = Point::new(32.0, 32.0); + assert_ne!(pixel(&before, app.canvas.shown.unwrap(), point), Color32::WHITE); + + app.run("object.fill", json!({"swatch": "[Paper]"})).unwrap(); + let patch = render(&mut app, &ctx); + assert!(patch.pos.is_some(), "an ordinary small edit should keep the partial repaint optimization"); + assert_eq!(app.canvas.patches, 1); + assert_eq!(pixel(&patch, app.canvas.shown.unwrap(), point), Color32::WHITE); +} From 1e153c5c4533993cf89799f23ae47b19093c9617 Mon Sep 17 00:00:00 2001 From: Daniel van Lith Date: Wed, 14 Oct 2026 10:36:52 +1300 Subject: [PATCH 110/200] macOS DMG: hdiutil create -srcfolder, so the app inside passes codesign --strict makehybrid -hfs gave every file in the image a com.apple.FinderInfo, so the app in the DMG, and a copy installed from it, failed codesign --verify --strict --deep ("resource fork, Finder information, or similar detritus not allowed"), though Gatekeeper accepts it. Build the image as VectorCraft (#1085) and PdfCraft (#823) do now: hdiutil create -srcfolder with a few tries for CI's "Resource busy", and verify the mounted app with --strict --deep so the regression can't ship again. Co-Authored-By: Claude Opus 5.5 --- packaging/macos/package.sh | 39 +++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 38fa2afb9..c818ae6db 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -135,15 +135,40 @@ ln -s /Applications "$STAGE/Applications" mkdir -p "$STAGE/.background" cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" -rm -f "$DMG" "$WORK/raw.dmg" -# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, -# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -# The volume name has no version: .DS_Store finds the background through an alias that includes it. -hdiutil makehybrid -hfs -hfs-volume-name "DesignCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" -hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" -rm -f "$WORK/raw.dmg" +rm -f "$DMG" +# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a +# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or +# similar detritus" on the app in the image and on a copy installed from it (as VectorCraft's +# storytold/vectorcraft#1085 and PdfCraft's storytold/pdfcraft#823). `create` attaches a device +# while it copies, which fails now and then on CI runners ("Resource busy"), so it gets a few +# tries. The volume name has no version: .DS_Store finds the background through an alias that +# includes it. +# Usage: hdiutil_retry ; tries five times with growing pauses. +hdiutil_retry() { + local what="$1" attempt + shift + for attempt in 1 2 3 4 5; do + if hdiutil "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2 + sleep $((attempt * 10)) + done +} +hdiutil_retry create create -srcfolder "$STAGE" -volname "DesignCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" +# The app inside the image must still pass --strict, so a makehybrid-style regression can't ship. +MOUNT="$WORK/dmg-check" +rm -rf "$MOUNT" +mkdir -p "$MOUNT" +hdiutil_retry attach attach -nobrowse -readonly -mountpoint "$MOUNT" "$DMG" >/dev/null +if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/DesignCraft.app"; then + hdiutil_retry detach detach "$MOUNT" >/dev/null || true + exit 1 +fi +hdiutil_retry detach detach "$MOUNT" >/dev/null if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" xcrun stapler staple "$DMG" From bfa536352dea4d0673d05fd8285caf9f82bf2d6e Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:54:46 +1000 Subject: [PATCH 111/200] FIX-CHILD-NOTE-PASTE: filter unsupported markers in child stories Co-Authored-By: Codex --- crates/engine/src/cmd/edit.rs | 128 +++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e7190acb4..79d136a14 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -74,7 +74,7 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< } } let use_clip = formatted && clip.as_ref().is_some_and(|(_, p)| text.as_ref().is_none_or(|x| x.replace("\r\n", "\n") == *p)); - // Footnotes, cross-references and tables only paste into story text, not cells or footnotes. + // Footnotes, endnotes, editorial notes, cross-references and tables cannot be pasted into cells or footnotes. if use_clip && let Some((slice, _)) = clip { let slice = if t.cell.is_some() { strip_objects(&slice) } else { (*slice).clone() }; return s.edit(|d, sel| { @@ -93,7 +93,13 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story { let mut out = st.clone(); - let marks = [designcraft_doc::FOOTNOTE_REF, designcraft_doc::XREF_MARK, designcraft_doc::TABLE_ANCHOR]; + let marks = [ + designcraft_doc::FOOTNOTE_REF, + designcraft_doc::ENDNOTE_REF, + designcraft_doc::NOTE_MARK, + designcraft_doc::XREF_MARK, + designcraft_doc::TABLE_ANCHOR, + ]; while let Some(i) = out.text.find(marks) { let n = out.text[i..].chars().next().map_or(1, char::len_utf8); out.delete(i..i + n); @@ -355,6 +361,124 @@ mod text_clipboard_tests { s.doc().unwrap().doc.check().unwrap(); } + fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let source = frame(&mut s, "aéz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap(); + s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap(); + s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap(); + s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"})); + (s, source) + } + + fn assert_child_note_paste( + s: &mut Session, + source: designcraft_doc::StoryId, + parent: designcraft_doc::StoryId, + cell: designcraft_doc::CellAddr, + endpoints: (usize, usize), + ) { + let before = s.doc().unwrap().doc.clone(); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1)); + assert_eq!(selected.frame, None); + let before_child = before.text_story(parent, Some(cell)).unwrap(); + assert_eq!(before_child.text, "L----R"); + let clipboard = s.text_clipboard.clone(); + let history = s.execute("document.history", &json!({})).unwrap(); + assert_eq!(history["redo"], json!([])); + + let result = s.execute("edit.paste", &json!({})).unwrap(); + let after = s.doc().unwrap().doc.clone(); + let child = after.text_story(parent, Some(cell)).unwrap(); + assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text"); + assert_eq!(child.len(), 6); + assert_eq!(result, json!({"pos": 5, "formatted": true})); + assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected })); + assert!(child.endnotes.is_empty() && child.editorial.is_empty()); + for pos in [1, 2, 4] { + let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(format.over.font_style.as_deref(), Some("Bold")); + } + // Inspect actual run coverage: char_format_at at a run boundary means the preceding run. + for pos in [0, 5] { + let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(new_format, old_format); + } + assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text); + assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars); + assert_eq!(after.story(source), before.story(source)); + assert_eq!(s.text_clipboard, clipboard); + + let mut pasted_history = history.clone(); + pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste")); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, before); + let mut undone_history = history; + undone_history["redo"] = json!(["Paste"]); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history); + // This filter does not change which selection Undo restores. + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, after); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); + } + + #[test] + fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "L----R"})).unwrap(); + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap(); + s.execute("table.prevCell", &json!({})).unwrap(); + s.execute("text.move", &json!({"dir": "storyStart"})).unwrap(); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone(); + let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 }; + assert_child_note_paste(&mut s, source, parent, cell, (1, 5)); + let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id]; + assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR"); + let mut expected_table = (*before_table).clone(); + expected_table.cells[0].text = table.cells[0].text.clone(); + assert_eq!(table.as_ref(), &expected_table); + + // The same clipboard must still retain both markers when pasted into an ordinary root story. + let root = frame(&mut s, "L----R"); + s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap(); + assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true})); + assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR"); + } + + #[test] + fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); + } + let before = s.doc().unwrap().doc.story(parent).unwrap().clone(); + assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1)); + let after = s.doc().unwrap().doc.story(parent).unwrap(); + assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR"); + assert_eq!(after.note(neighbor), before.note(neighbor)); + assert_eq!(after.notes.iter().map(|note| note.id).collect::>(), before.notes.iter().map(|note| note.id).collect::>()); + } + #[test] fn change_case_keeps_runs() { let mut s = Session::new(); From 5024ed8870a4c4cba5f1b6ca20a5914e55237944 Mon Sep 17 00:00:00 2001 From: Andreas Haraminac Date: Tue, 13 Oct 2026 21:55:44 +0000 Subject: [PATCH 112/200] #732: bound control-channel click and drag event queues --- ROADMAP.md | 3 +- crates/ui-egui/src/control.rs | 149 +++++++++++++++++++++++++++++++++- docs/control-protocol.md | 13 ++- docs/gaps.md | 4 +- 4 files changed, 163 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..6c6c09c76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **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-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. 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/control.rs b/crates/ui-egui/src/control.rs index 5a4de0058..232f2ca21 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -100,6 +100,27 @@ fn key_from(name: &str) -> Option { }) } +// Retain room for the existing maximum drag (10,000 moves plus move/down/up), but +// bound the whole gesture and already queued input before generating any events. +const MAX_PENDING_POINTER_EVENTS: usize = 10_003; + +fn pointer_counts(p: &Value, drag: bool, pending: usize) -> Result<(u64, u64), String> { + let positive = |name: &str, default: u64| match p.get(name) { + None => Ok(default), + Some(v) => v.as_u64().filter(|n| *n > 0).ok_or_else(|| format!("`{name}` must be a positive integer")), + }; + let count = positive("count", 1)?; + let steps = if drag { positive("steps", 8)? } else { 0 }; + // One initial move, two button events per click, and each drag step. Check the + // arithmetic too: hostile counts must never overflow into a small budget. + let events = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)); + let available = MAX_PENDING_POINTER_EVENTS.saturating_sub(pending) as u64; + if events.is_none_or(|n| n > available) { + return Err(format!("pointer input would exceed the {MAX_PENDING_POINTER_EVENTS} pending-event limit")); + } + Ok((count, steps)) +} + pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -181,6 +202,10 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.click" | "ui.drag" => { // Screen-space (egui points) pointer input through egui itself: reaches every widget. + let (count, steps) = match pointer_counts(p, req.method == "ui.drag", app.synthetic.len()) { + Ok(counts) => counts, + Err(e) => return err(e), + }; let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { Some("right") | Some("secondary") => egui::PointerButton::Secondary, @@ -200,15 +225,12 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { - // From the caller: a huge count would queue events until memory runs out. - let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 10_000); for i in 1..=steps { let t = i as f32 / steps as f32; app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); } } app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); for _ in 1..count { app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); @@ -420,6 +442,127 @@ mod automation_contract_tests { output.textures_delta.clear(); } #[test] + fn oversized_pointer_requests_leave_pending_events_unchanged() { + let mut app = app(); + let ctx = egui::Context::default(); + let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))]; + for (method, params) in [ + ("ui.click", json!({"count": 5002})), + ("ui.drag", json!({"steps": 10000, "count": 2})), + ("ui.drag", json!({"steps": 10001})), + ("ui.click", json!({"count": u64::MAX})), + ("ui.drag", json!({"steps": u64::MAX})), + ("ui.drag", json!({"count": u64::MAX, "steps": u64::MAX})), + ] { + app.synthetic = pending.clone(); + let reply = call(&mut app, &ctx, method, params); + assert_eq!(reply["ok"], false, "{method}: {reply}"); + assert_eq!(app.synthetic, pending, "a rejected gesture must not leave a press queued"); + } + } + #[test] + fn pointer_counts_require_positive_json_integers() { + let mut app = app(); + let ctx = egui::Context::default(); + let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))]; + app.synthetic = pending.clone(); + for value in [json!(0), json!(-1), json!(1.5), Value::Null, json!("2"), json!(true), json!(18446744073709551616_f64)] { + for (method, field) in [("ui.click", "count"), ("ui.drag", "count"), ("ui.drag", "steps")] { + let mut params = json!({}); + params[field] = value.clone(); + let reply = call(&mut app, &ctx, method, params); + assert_eq!(reply["ok"], false, "{method}, {field}={value}: {reply}"); + assert!(reply["error"].as_str().unwrap().contains(field)); + assert_eq!(app.synthetic, pending); + } + } + } + #[test] + fn pointer_gestures_accept_the_exact_event_budget() { + let mut app = app(); + let ctx = egui::Context::default(); + for (method, params) in + [("ui.click", json!({"count": 5001})), ("ui.drag", json!({"steps": 10000})), ("ui.drag", json!({"steps": 9998, "count": 2}))] + { + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, method, params)["ok"], true, "{method}"); + assert_eq!(app.synthetic.len(), 10003); + assert!(matches!(app.synthetic.last(), Some(egui::Event::PointerButton { pressed: false, .. }))); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false); + assert_eq!(app.synthetic, queued, "a full queue retains its release events"); + } + } + #[test] + fn pointer_budget_counts_pending_input_and_recovers_when_it_drains() { + let mut app = app(); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({"count": 5000}))["ok"], true); + assert_eq!(app.synthetic.len(), 10001); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false); + assert_eq!(app.synthetic, queued); + // The ordinary input hook consumes one pointer event per frame, freeing + // enough room for a complete click without dropping earlier input. + app.raw_input_hook(&mut egui::RawInput::default()); + assert_eq!(app.synthetic.len(), 10000); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], true); + assert_eq!(app.synthetic.len(), 10003); + + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, "ui.move", json!({"x": 12, "y": 34}))["ok"], true); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 10000}))["ok"], false); + assert_eq!(app.synthetic, queued); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 9999}))["ok"], true); + assert_eq!(app.synthetic.len(), 10003); + } + #[test] + fn ordinary_pointer_gestures_keep_their_defaults_and_event_order() { + let mut app = app(); + let ctx = egui::Context::default(); + let a = egui::pos2(10.0, 20.0); + let end = egui::pos2(30.0, 40.0); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({"x": 10, "y": 20}))["ok"], true); + assert_eq!( + app.synthetic, + vec![ + egui::Event::PointerMoved(a), + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }, + ] + ); + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"x": 10, "y": 20, "toX": 30, "toY": 40}))["ok"], true); + assert_eq!(app.synthetic.len(), 11, "default drag has eight steps"); + assert_eq!(app.synthetic.get(9), Some(&egui::Event::PointerMoved(end))); + + app.synthetic.clear(); + assert_eq!( + call( + &mut app, + &ctx, + "ui.drag", + json!({"x": 10, "y": 20, "toX": 30, "toY": 40, "steps": 2, "count": 2, "button": "right", "shift": true}) + )["ok"], + true + ); + let modifiers = egui::Modifiers { shift: true, ..Default::default() }; + let button = egui::PointerButton::Secondary; + assert_eq!( + app.synthetic, + vec![ + egui::Event::PointerMoved(a), + egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }, + egui::Event::PointerMoved(egui::pos2(20.0, 30.0)), + egui::Event::PointerMoved(end), + egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }, + egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }, + egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }, + ] + ); + } + #[test] fn control_typing_uses_explicit_caret_but_retains_widget_events() { let mut app = app(); let ctx = egui::Context::default(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9e5145616..6615eb103 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,7 +39,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas"}], mods?}` | Drive the active tool through the same code path as the mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | -| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel | +| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle`, `count?: positive integer` (click/drag, default 1), `steps?: positive integer` (drag, default 8) | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | | `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) | | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | @@ -57,6 +57,17 @@ Headless window screenshots (locked screen, hidden window): `cargo run -p design The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents. +### Click and drag limits + +`ui.click` and `ui.drag` validate the whole gesture before queuing any input. They permit at +most 10,003 pending synthetic events, including input already waiting for native frames. A +click uses `1 + 2 * count` events; a drag uses `1 + steps + 2 * count`. The count includes the +initial click or drag; further clicks occur at its endpoint. An empty queue therefore accepts +up to 5,001 clicks, or a single drag with up to 10,000 steps. `count` and `steps` must be positive +JSON integers when supplied; invalid or over-budget requests return `ok: false` and leave pending +input unchanged. Oversized gestures are rejected rather than truncated. Wait for queued input +to finish before retrying a request that would exceed the pending-event limit. + ## Shared panel docking Panel arrangements support tab grouping and reordering, horizontal and vertical splits, diff --git a/docs/gaps.md b/docs/gaps.md index ebd2d7da5..a578036d6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -38,6 +38,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · open (#238, #187, #160, #312); threading (gap 0); double-click word selection in fields (#189); character formatting set at a caret is discarded (#289). - **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds. +- **Safety:** control-channel clicks and drags reject invalid or over-budget counts before + queuing input (#732), including combined drag steps, repeated clicks and already pending events. - **Impact:** a layout artist can't finish the simplest job. This dominates the ready-for-real-work number. - **Estimate:** 30–50 h (reproduce per platform through the control channel, regression tests). From 0aaaa9600d607cef7e480ad3373fa4f53e3c2d4e Mon Sep 17 00:00:00 2001 From: Seon-Jin Yoon Date: Wed, 14 Oct 2026 07:12:08 +0900 Subject: [PATCH 113/200] fix(compose): odd/even page starts keep their parity at the top of a frame Start options were skipped for a paragraph at the top of a frame. That is right for Next Page, Next Frame and Next Column (there is nothing to break away from), but a Next Odd Page paragraph that lands at the top of an even page stayed there, as did Next Even Page at the top of an odd page, and the same went for odd and even page break characters. A part or chapter opener whose previous section ended exactly at the bottom of an odd page therefore opened on a left-hand page. Cursor::start_at_top now moves such a paragraph to the next page of the asked parity, for paragraphs, tables and break characters alike. Other start options at the top of a frame are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 34 ++++++++++++++++++++++++++++------ crates/compose/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index cd0e7cbd9..6d4c4bb9f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -576,8 +576,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .and_then(|t| t.chars().next_back()) .and_then(break_start); if let Some(t) = story.para_table(pi) { + let start = after_break.unwrap_or(pp.start_paragraph); if cur.last_baseline.is_some() { - cur.start(after_break.unwrap_or(pp.start_paragraph), &cols, frames, doc); + cur.start(start, &cols, frames, doc); + } else { + cur.start_at_top(start, frames, doc); } let at_top = cur.last_baseline.is_none(); let start_at = (cur.fi, cur.col); @@ -796,17 +799,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C StartParagraph::NextColumn => next_column(cur), other => cur.start(other, &cols, frames, doc), }; - // Paragraph start options. - if let Some(start) = after_break - && cur.last_baseline.is_some() - { - begin_at(&mut cur, start); + // Paragraph start options. At the top of a frame there is nothing to break away from, but + // an odd or even page start still has to land on a page of that parity. + if let Some(start) = after_break { + if cur.last_baseline.is_some() { + begin_at(&mut cur, start); + } else { + cur.start_at_top(start, frames, doc); + } } if force_col[pi] { next_column(&mut cur); } if cur.last_baseline.is_some() { begin_at(&mut cur, pp.start_paragraph); + } else { + cur.start_at_top(pp.start_paragraph, frames, doc); } if spanning && cur.fi < frames.len() { // Close the band: the paragraph goes below the deepest of its columns, which are @@ -1983,6 +1991,20 @@ impl Cursor { StartParagraph::NextEvenPage => self.next_page(frames, doc, Some(false)), } } + /// A start option for a paragraph at the top of a frame: there is nothing to break away from, + /// but an odd or even page start still has to land on a page of that parity, so a paragraph + /// at the top of an even page that asks for the next odd page moves on (and vice versa). + fn start_at_top(&mut self, start: StartParagraph, frames: &[FrameSpec], doc: &Document) { + let want_odd = match start { + StartParagraph::NextOddPage => true, + StartParagraph::NextEvenPage => false, + _ => return, + }; + let Some(page) = frames.get(self.fi).and_then(|f| f.page) else { return }; + if (doc.page_number(page) % 2 == 1) != want_odd { + self.next_page(frames, doc, Some(want_odd)); + } + } /// Move to the next frame on a later page, one with an odd page number (`odd` true) or an /// even one (false) when asked. Frames without a page (parent pages) qualify; with no frame /// left, the rest of the story is overset. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7a0e790d5..f6f5c9ad8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -595,6 +595,29 @@ fn break_characters_end_their_paragraph_and_keep_page_parity() { assert_eq!(cs.overset_at, Some(text.find("four").unwrap())); } +/// A paragraph that starts at the top of a frame isn't pushed on by Next Page, but Next Odd Page +/// (or Next Even Page) still moves it when that frame is on a page of the other parity. +#[test] +fn odd_and_even_page_starts_keep_parity_at_the_top_of_a_frame() { + use designcraft_doc::StartParagraph; + use designcraft_doc::story::PAGE_BREAK; + let pages_of = |start: StartParagraph| { + let mut d = Document::new(&NewDocument { pages: 4, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + // "two" begins at the top of page 2, after the page break that ends "one". + d.story_mut(sid).unwrap().insert(0, &format!("one{PAGE_BREAK}\ntwo\nthree")); + d.story_mut(sid).unwrap().paras[1].para.start_paragraph = Some(start); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + // Frame i is on page i + 1. + cs.frames.iter().enumerate().flat_map(|(fi, f)| f.lines.iter().map(move |l| (l.para, fi + 1))).collect::>() + }; + // Already at the top of an even page: Next Page and Next Even Page leave "two" where it is. + assert_eq!(pages_of(StartParagraph::NextPage), vec![(0, 1), (1, 2), (2, 2)]); + assert_eq!(pages_of(StartParagraph::NextEvenPage), vec![(0, 1), (1, 2), (2, 2)]); + // Next Odd Page takes it to page 3, leaving page 2 empty. + assert_eq!(pages_of(StartParagraph::NextOddPage), vec![(0, 1), (1, 3), (2, 3)]); +} + #[test] fn line_before_a_break_character_is_a_last_line() { // As in InDesign: column, frame and page breaks end the paragraph's last line (set with the From cc13e80c9d3ebfe3453b8fcb3aad0165aa6b027e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 00:29:55 +0200 Subject: [PATCH 114/200] feat(tools): Page tool selects pages; handles preview the liquid rule, Alt-drag resizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:42– 2:06) selects a page with the Page tool, gets its bounding box with handles, drags a handle (the page springs back: a liquid-rule preview), holds Option to really resize it, Shift for proportions, and reads the size in a tooltip by the cursor. DesignCraft's Page tool only opened a generic Page Size dialog on click. - tools: the Page tool selects the page under a click (none on the pasteboard); the selected page shows its outline, eight handles and its liquid page rule ("Scale", "Object-Based"…) above it. Dragging a handle previews layout.pageSize (the liquid rule re-flows the objects) and springs back on release; Alt resizes, Shift keeps the proportions; a W/H readout follows the pointer; resize cursors on the handles. Double-click a page for the Page Size dialog. - doc: Selection.page (the Page tool's page); engine: layout.selectPage (no undo step). - layout.pageSize `anchor` (0-8): the page grows away from the reference point and its objects and guides keep their place relative to it (a handle drag anchors the opposite edge); liquid rules work from that. - canvas: PageHandles and Tag overlays; translations for the tags and the Resize Page undo label. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/selection.rs | 3 + crates/engine/src/cmd/layout.rs | 61 +++++++++++- crates/engine/src/tests.rs | 73 ++++++++++++++ crates/tools/src/lib.rs | 10 +- crates/tools/src/nav.rs | 160 ++++++++++++++++++++++++++++--- crates/tools/src/tests.rs | 12 ++- crates/ui-egui/src/canvas.rs | 21 ++++ crates/ui-egui/src/i18n.rs | 3 + crates/ui-egui/src/i18n/ar.rs | 3 + crates/ui-egui/src/i18n/it.rs | 3 + crates/ui-egui/src/i18n/ja.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 3 + crates/ui-egui/src/i18n/uk.rs | 3 + docs/target-app-parity.md | 2 +- 15 files changed, 340 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..050c07d9a 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-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** · 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/doc/src/selection.rs b/crates/doc/src/selection.rs index d9e9b7a3d..2d28bf2c2 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -80,6 +80,9 @@ pub struct Selection { /// Key object for Align. #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option, + /// The page selected with the Page tool (absolute index). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page: Option, } impl Selection { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..2c23640f1 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -376,10 +376,24 @@ pub fn specs() -> Vec { "Page Size", [], None, - "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…} — pages of their own size (Page tool); objects on the pages keep their place on them", + "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule", has_doc, page_size ), + cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { + let n = s.doc()?.doc.page_count(); + let page = match p.get("page") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), + }; + let st = s.doc_mut()?; + if page.is_some() { + st.selection = Default::default(); + } + st.selection.page = page; + st.revision += 1; + Ok(json!({"page": page.map(|p| p + 1)})) + }), cmd!( "layout.section", "Numbering & Section Options…", @@ -640,8 +654,12 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { w = Some(pr.width); h = Some(pr.height); } + let anchor = match p.get("anchor") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().filter(|a| *a <= 8).ok_or_else(|| bad("layout.pageSize", "`anchor`: 0–8"))? as usize, + }; s.edit(|d, _| { - resize_pages(d, &pages, w, h); + resize_pages_from(d, &pages, w, h, anchor); Ok(json!({"pages": pages.len()})) }) } @@ -649,14 +667,35 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { /// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule /// re-flow their objects by it. pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { + resize_pages_from(d, pages, w, h, 0); +} + +/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: +/// the page grows away from it, so objects keep their place relative to it. +pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option, anchor: usize) { + let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); { for page in pages { let Some((si, pi)) = d.page_loc(page - 1) else { continue }; let sp = std::sync::Arc::make_mut(&mut d.spreads[si]); let old: Vec = sp.pages.iter().map(|x| x.x).collect(); let old_bounds = sp.pages[pi].bounds(); + // The page's own objects (outer pages take the pasteboard beside them). + let last = sp.pages.len() - 1; + let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); + let own: Vec = sp + .items + .iter() + .enumerate() + .filter(|(_, it)| { + let c = it.bounds().center().x; + c >= lo && c < hi + }) + .map(|(i, _)| i) + .collect(); let pg = &mut sp.pages[pi]; let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width); + let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height); if let Some(w) = w { pg.width = w.max(1.0); } @@ -693,8 +732,22 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w } } } - // Liquid rules: objects were carried along with the page's left edge. - let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0); + // Growing away from the anchor: the page's objects and guides keep their place + // relative to it. + let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); + if shift != designcraft_geom::Vec2::ZERO { + for i in &own { + if let Some(it) = sp.items.get_mut(*i) { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate(shift) * it.xf; + } + } + for g in &mut sp.pages[pi].guides { + g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; + } + } + // Liquid rules: objects were carried along with the page's left edge (and the anchor). + let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed098..deec4b9b1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,76 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn page_tool_selects_a_page_and_its_handles_preview_or_resize_it() { + use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + let width = |s: &Session| s.doc().unwrap().doc.spreads[0].pages[0].width; + // The page on the canvas (a single page is centred on the spine). + let page = |s: &Session| { + let d = &s.doc().unwrap().doc; + CanvasLayout::new(d, false).page_rect(d, 0).unwrap() + }; + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let ev = |kind, x: f64, y: f64, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, x, y) }; + let drag = |s: &mut Session, from: (f64, f64), by: f64, alt: bool, check: &dyn Fn(&Session)| { + s.pointer(&ev(PointerKind::Down, from.0, from.1, alt), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, from.0 + by, from.1, alt), v).unwrap(); + check(s); + s.pointer(&ev(PointerKind::Up, from.0 + by, from.1, alt), v).unwrap(); + }; + // A click selects the page. + let c = page(&s).center(); + s.pointer(&ev(PointerKind::Down, c.x, c.y, false), v).unwrap(); + s.pointer(&ev(PointerKind::Up, c.x, c.y, false), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, Some(0)); + // A plain drag of the right handle previews the page's liquid rule and springs back. + s.execute("liquid.pageRule", &json!({"rule": "scale"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = page(&s); + drag(&mut s, (r.x1, r.center().y), 612.0, false, &|s| { + assert_eq!(width(s), 1224.0, "previewed"); + // Scale keeps proportions: only wider, so the frame is centred on the wider page. + assert_eq!(frame(s).x0, 706.0, "the Scale rule in the preview: {:?}", frame(s)); + }); + assert_eq!((width(&s), frame(&s)), (612.0, designcraft_geom::Rect::new(400.0, 100.0, 500.0, 200.0)), "sprung back"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing to undo"); + // Alt-drag resizes; with the rule off the frame stays where it is. + s.execute("liquid.pageRule", &json!({"rule": "off"})).unwrap(); + let r = page(&s); + drag(&mut s, (r.x1, r.center().y), 100.0, true, &|_| {}); + assert_eq!(width(&s), 712.0); + assert_eq!(frame(&s).x0, 400.0); + // The left handle grows the page to the left: the frame keeps its place by the right edge. + let r = page(&s); + drag(&mut s, (r.x0, r.center().y), -100.0, true, &|_| {}); + let pg = s.doc().unwrap().doc.spreads[0].pages[0].clone(); + assert_eq!(pg.width, 812.0); + assert_eq!(frame(&s).x0 - pg.x, 500.0, "100 pt further from the page's left edge"); + // A click on the pasteboard deselects the page. + let r = page(&s); + s.pointer(&ev(PointerKind::Down, r.x0 - 200.0, r.center().y, false), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, None); +} + +#[test] +fn page_size_keeps_objects_by_its_anchor() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + // Bottom-right stays put: 100 wider and 50 taller moves the frame 100 right and 50 down. + s.execute("layout.pageSize", &json!({"pages": [1], "width": 500, "height": 450, "anchor": 8})).unwrap(); + assert_eq!(frame(&s), designcraft_geom::Rect::new(200.0, 150.0, 300.0, 250.0)); + // The centre: half of it. + s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "height": 450, "anchor": 4})).unwrap(); + assert_eq!(frame(&s).x0, 300.0); + assert!(s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "anchor": 9})).is_err()); + assert!(s.execute("layout.selectPage", &json!({"page": 2})).is_err()); + assert_eq!(s.execute("layout.selectPage", &json!({"page": 1})).unwrap()["page"], 1); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f3de390a4..dd09e9c25 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -314,6 +314,14 @@ pub enum Overlay { b: Point, label: String, }, + /// A page selected with the Page tool (canvas rect): its outline and eight handles. + PageHandles(Rect), + /// A small tag whose bottom-left is at `p` (the Page tool's liquid page rule; English, the UI + /// translates it). + Tag { + p: Point, + text: String, + }, } /// Correction from a snap, in spread coordinates for `delta`. @@ -400,7 +408,7 @@ pub fn create(id: &str) -> Box { "hand" => Box::new(nav::HandTool::default()), "placeGun" => Box::new(nav::PlaceGun::default()), "zoom" => Box::new(nav::ZoomTool::default()), - "page" => Box::new(nav::PageTool), + "page" => Box::new(nav::PageTool::default()), "typeOnPath" => Box::new(nav::PathTypeTool), "gap" => Box::new(nav::GapTool::default()), "contentCollector" => Box::new(nav::ConveyorTool::new(false)), diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index d892b090a..a94a93881 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -3,7 +3,7 @@ use designcraft_geom::Point; use serde_json::json; -use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; /// Hand tool: drag to pan. Alt-press starts Power Zoom: the view zooms out to the spread, the /// pointer aims a rectangle of the previous view, and releasing zooms back in there. @@ -162,27 +162,161 @@ impl Tool for PlaceGun { } } -/// Page tool (Shift+P): click a page to set its own size. +/// Page tool (Shift+P): click a page to select it; drag one of its handles to preview its liquid +/// page rule (the page springs back on release), Alt-drag to resize it, Shift to keep its +/// proportions; double-click a page for exact sizes. #[derive(Default)] -pub struct PageTool; +pub struct PageTool { + drag: Option, +} + +/// A page handle being dragged. +#[derive(Clone, Copy, Debug)] +struct PageDrag { + /// Absolute page index. + page: usize, + /// Reference-point index of the handle (0–8, row-major; 4 is the centre and has none). + handle: usize, + start: Point, + cur: Point, + /// The page's size when the drag began. + size: (f64, f64), + alt: bool, +} + +/// The eight handles of a page rect: (reference-point index, canvas point). +fn page_handles(r: designcraft_geom::Rect) -> impl Iterator { + (0..9).filter(|i| *i != 4).map(move |i| (i, Point::new(r.x0 + r.width() * (i % 3) as f64 / 2.0, r.y0 + r.height() * (i / 3) as f64 / 2.0))) +} + +/// The liquid page rule as the Page tool tags a page. +pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { + use designcraft_doc::LiquidRule as R; + match rule { + R::Off => "Off", + R::Scale => "Scale", + R::ReCenter => "Re-center", + R::ObjectBased => "Object-Based", + R::GuideBased => "Guide-Based", + } +} + +impl PageTool { + /// The selected page's handle under `p`. + fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> { + let page = cx.selection.page?; + let r = cx.layout.page_rect(cx.doc, page)?; + page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (page, i)) + } + + /// The new page size for the drag at `p`: the handle's edges follow the pointer (Shift: in + /// proportion). + fn size_at(cx: &ToolContext, d: &PageDrag, p: Point, shift: bool) -> (f64, f64) { + let si = cx.doc.page_loc(d.page).map_or(0, |l| l.0); + let v = cx.layout.delta_to_spread(designcraft_doc::SpreadRef::Doc(si), p - d.start); + let (col, row) = (d.handle % 3, d.handle / 3); + let dx = match col { + 0 => -v.x, + 2 => v.x, + _ => 0.0, + }; + let dy = match row { + 0 => -v.y, + 2 => v.y, + _ => 0.0, + }; + let (w0, h0) = d.size; + let (mut w, mut h) = ((w0 + dx).max(1.0), (h0 + dy).max(1.0)); + if shift { + // The axis being dragged (the larger change at a corner) sets the scale. + let k = match (col == 1, row == 1) { + (true, _) => h / h0, + (_, true) => w / w0, + _ if (w / w0 - 1.0).abs() >= (h / h0 - 1.0).abs() => w / w0, + _ => h / h0, + }; + (w, h) = ((w0 * k).max(1.0), (h0 * k).max(1.0)); + } + (w, h) + } +} impl Tool for PageTool { fn id(&self) -> &'static str { "page" } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - if ev.kind != PointerKind::Up { - return vec![]; + match ev.kind { + PointerKind::Down => { + if let Some((page, handle)) = self.handle_at(cx, ev.pos) { + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt }); + return vec![Action::Begin("Resize Page".into())]; + } + // A click selects the page under the pointer (none on the pasteboard). + let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))); + if page == cx.selection.page { + return vec![]; + } + vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1)}))] + } + PointerKind::Drag => { + let Some(d) = self.drag.as_mut() else { return vec![] }; + d.cur = ev.pos; + d.alt |= ev.mods.alt; + let d = *d; + let (w, h) = Self::size_at(cx, &d, ev.pos, ev.mods.shift); + vec![Action::Preview("layout.pageSize".into(), json!({"pages": [d.page + 1], "width": w, "height": h, "anchor": 8 - d.handle}))] + } + PointerKind::Up => match self.drag.take() { + // Alt-drag resizes; a plain drag previews the liquid rule and springs back. + Some(d) if d.alt || ev.mods.alt => vec![Action::Commit], + Some(_) => vec![Action::Cancel], + None => vec![], + }, + PointerKind::DoubleClick => { + // Exact sizes: the Page Size dialog for the page. + let Some(page) = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))) else { + return vec![]; + }; + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))] + } + PointerKind::Move => vec![], } - let Some((designcraft_doc::SpreadRef::Doc(si), sp)) = cx.layout.spread_at(ev.pos) else { return vec![] }; - let Some(spread) = cx.doc.spreads.get(si) else { return vec![] }; - let Some(pi) = spread.pages.iter().position(|p| p.bounds().contains(sp)) else { return vec![] }; - let pg = &spread.pages[pi]; - let n = cx.doc.first_page_of_spread(si) + pi + 1; - vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [n], "width": pg.width, "height": pg.height}))] } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Arrow + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(page) = cx.selection.page else { return vec![] }; + let Some(r) = cx.layout.page_rect(cx.doc, page) else { return vec![] }; + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }]; + if let Some(d) = self.drag { + v.push(Overlay::Measure { + p: d.cur, + text: format!( + "W: {}\nH: {}", + designcraft_geom::format_measure(pg.width, cx.unit), + designcraft_geom::format_measure(pg.height, cx.unit) + ), + }); + } + v + } + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h)); + match handle { + Some(1 | 7) => Cursor::ResizeV, + Some(3 | 5) => Cursor::ResizeH, + Some(0 | 8) => Cursor::ResizeNwSe, + Some(_) => Cursor::ResizeNeSw, + None => Cursor::Arrow, + } + } + fn busy(&self) -> bool { + self.drag.is_some() } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6776902aa..b2e597242 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -227,14 +227,22 @@ fn hand_tool_alt_press_power_zooms() { } #[test] -fn page_tool_opens_page_size() { +fn page_tool_selects_a_page_and_opens_page_size_on_double_click() { 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 off = l.xf(SpreadRef::Doc(0)).translation(); + let (x, y) = (200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y); let mut t = create("page"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1}))]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y)); assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]); + // The selected page shows its handles and its liquid page rule. + let s = Selection { page: Some(0), ..Default::default() }; + let cx = ctx(&d, &s, &c, &l); + let o = t.overlays(&cx); + assert!(matches!(o.as_slice(), [Overlay::PageHandles(_), Overlay::Tag { text, .. }] if text == "Off"), "{o:?}"); } #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..b3fa92336 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1237,6 +1237,27 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.rect_filled(r, 3.0, tok.measure_bg); painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); } + Overlay::PageHandles(r) => { + // The Page tool's selected page: outline and eight handles, as InDesign draws them. + let r = xf.rect(r); + let col = Color32::from_rgb(79, 153, 255); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Middle); + for i in (0..9).filter(|i| *i != 4) { + let c = pos2(r.min.x + r.width() * (i % 3) as f32 / 2.0, r.min.y + r.height() * (i / 3) as f32 / 2.0); + let h = Rect::from_center_size(c, vec2(7.0, 7.0)); + painter.rect_filled(h, 0.0, Color32::WHITE); + painter.rect_stroke(h, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + } + } + Overlay::Tag { p, text } => { + let text = crate::i18n::tr(&app.ui.language, &text).to_owned(); + let s = xf.to_screen(p); + let g = painter.layout_no_wrap(text, egui::FontId::proportional(10.0), tok.measure_text); + let r = Rect::from_min_size(s - vec2(0.0, g.size().y + 4.0), g.size() + vec2(8.0, 4.0)); + painter.rect_filled(r, 2.0, tok.measure_bg); + painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(79, 153, 255)), StrokeKind::Inside); + painter.galley(r.min + vec2(4.0, 2.0), g, tok.measure_text); + } Overlay::Path { .. } => {} } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..63c70d4a7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2498,6 +2498,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Added", ["Added", "Added", "Added", "Added", "已添加"]), ("Deleted", ["Deleted", "Deleted", "Deleted", "Deleted", "已删除"]), ("Guide-based", ["Guide-based", "Guide-based", "Guide-based", "Guide-based", "基于参考线"]), + ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), + ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), + ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..e0c17c88a 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -709,6 +709,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "مضاف"), ("Deleted", "محذوف"), ("Guide-based", "استنادًا إلى الأدلة"), + ("Object-Based", "استنادًا إلى الكائنات"), + ("Guide-Based", "استنادًا إلى الأدلة"), + ("Resize Page", "تغيير حجم الصفحة"), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f6..3f0915aa3 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1295,6 +1295,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Aggiunto"), ("Deleted", "Eliminato"), ("Guide-based", "Basato su guide"), + ("Object-Based", "Basato su oggetti"), + ("Guide-Based", "Basato su guide"), + ("Resize Page", "Ridimensiona pagina"), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c0..9e0f7a953 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1291,6 +1291,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "追加済み"), ("Deleted", "削除済み"), ("Guide-based", "ガイド基準"), + ("Object-Based", "オブジェクト基準"), + ("Guide-Based", "ガイド基準"), + ("Resize Page", "ページのサイズを変更"), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20d..e1fbf308a 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -708,6 +708,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Adicionado"), ("Deleted", "Excluído"), ("Guide-based", "Baseado em guias"), + ("Object-Based", "Baseado em objetos"), + ("Guide-Based", "Baseado em guias"), + ("Resize Page", "Redimensionar página"), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb35..5994dc135 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1292,6 +1292,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Додано"), ("Deleted", "Видалено"), ("Guide-based", "На основі напрямних"), + ("Object-Based", "На основі об’єктів"), + ("Guide-Based", "На основі напрямних"), + ("Resize Page", "Змінити розмір сторінки"), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..84b602d54 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 - 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. +- 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. - 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 554019cdd582207835a2f895344483389a016cb0 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:32:16 +0200 Subject: [PATCH 115/200] Tables: table.options limits header and footer rows (#861) table.options added as many rows as the requested header and footer counts needed, without a limit. A large number typed into the Table panel's Header Rows field froze the app and exhausted memory. Both counts are now capped at 100, the limit table.insert already has. --- crates/engine/src/cmd/table.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index ad306be8e..7851ab858 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -931,8 +931,9 @@ fn options(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("altCols") { o.alt_cols = parse_alt(v); } - let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v as usize); - let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v as usize); + // At most 100 of each, as in `table.insert`: the rows the table lacks are added below. + let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); + let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); if h.is_some() || f.is_some() { let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows())); // Add rows when there aren't enough for the requested header/footer plus one body row. @@ -1680,6 +1681,19 @@ mod sort_tests { s.execute("table.moveColumn", &json!({"from": 1, "to": 0})).unwrap(); assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string())); } + + #[test] + fn table_options_limits_header_and_footer_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + // The limit `table.insert` has: the table used to grow by as many rows as were asked for. + s.execute("table.options", &json!({"headerRows": 5000, "footerRows": 5000})).unwrap(); + let t = table(&s); + assert_eq!((t.header_rows(), t.footer_rows(), t.nrows()), (100, 100, 201)); + } } #[cfg(test)] From 59b6125af1c5402d6950ab8a92ff28125373a2f0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 00:51:47 +0200 Subject: [PATCH 116/200] feat(ui): Control panel for the Page tool; Controlled by Parent; Show Parent Page Overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (1:02– 2:06, 13:47) sizes pages from the Control panel with the Page tool selected: X/Y, W/H, the page-size preset ("US Business Card"), orientation, Liquid Page Rule ("Controlled by Parent"), Objects Move with Page and Show Parent Page Overlay. DesignCraft's Control panel showed the (empty) object controls. - ui: with the Page tool, the Control panel shows the selected page (or the page in view): the reference point (W/H, preset and orientation keep it in place via layout.pageSize's anchor), X/Y (read-only), W/H, the preset dropdown, portrait/landscape, Liquid Page Rule, Objects Move with Page (always on: pages don't move within spreads yet) and Show Parent Page Overlay. - doc: LiquidRule::ControlledByParent (the parent page's rule, Off without one; resolved by liquid::effective_rule); Page.show_parent_overlay. - engine: layout.pageOptions {pages, showParentOverlay}; liquid.pageRule takes controlledByParent. - canvas: the parent page's size over a page with the overlay on. - Liquid Layout panel and Control panel share InDesign's rule list (Off, Scale, Re-center, Object-based, Guide-based, Controlled by Parent); "US Business Card" preset; translations. Builds on the Page tool PR (#862) for the selected page. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/build.rs | 1 + crates/doc/src/page.rs | 6 ++ crates/engine/src/cmd/layout.rs | 29 ++++++ crates/engine/src/cmd/liquid.rs | 24 ++++- crates/engine/src/tests.rs | 25 +++++ crates/idml/src/import.rs | 1 + crates/tools/src/nav.rs | 1 + crates/ui-egui/src/canvas.rs | 12 ++- crates/ui-egui/src/chrome.rs | 111 ++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 29 ++++++ 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/panels/interactive.rs | 33 +++---- crates/ui-egui/src/panels/properties.rs | 7 +- 18 files changed, 280 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 050c07d9a..d9957d5d1 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-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** · 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** · 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. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c274f2130..574049d76 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -226,6 +226,7 @@ impl Document { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, } } diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index f1eaa48ad..6aa325798 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -114,6 +114,9 @@ pub struct Page { /// Liquid page rule: how objects follow when the page changes size. #[serde(default, skip_serializing_if = "LiquidRule::is_off")] pub liquid: LiquidRule, + /// Page tool › Show Parent Page Overlay: the parent page's size drawn over this page. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub show_parent_overlay: bool, /// Window › Interactive › Page Transitions (the spread's first page holds the spread's). #[serde(default, skip_serializing_if = "Option::is_none")] pub transition: Option, @@ -208,6 +211,8 @@ pub enum LiquidRule { GuideBased, /// Each object's pins and resize settings decide (`Item::liquid`). ObjectBased, + /// The rule of the page's parent page (InDesign's "Controlled by Parent"; Off without one). + ControlledByParent, } impl LiquidRule { @@ -367,6 +372,7 @@ mod tests { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, } } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 2c23640f1..4055b0809 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -380,6 +380,15 @@ pub fn specs() -> Vec { has_doc, page_size ), + cmd!( + "layout.pageOptions", + "Page Options", + [], + None, + "{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)", + has_doc, + page_options + ), cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { let n = s.doc()?.doc.page_count(); let page = match p.get("page") { @@ -664,6 +673,26 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { }) } +fn page_options(s: &mut crate::Session, p: &Value) -> Result { + let pages: Vec = + p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); + let n = s.doc()?.doc.page_count(); + if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { + return Err(bad("layout.pageOptions", "give `pages` (1-based)")); + } + let overlay = p.get("showParentOverlay").and_then(Value::as_bool); + s.edit(|d, _| { + for page in &pages { + let Some((si, pi)) = d.page_loc(page - 1) else { continue }; + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; + if let Some(v) = overlay { + pg.show_parent_overlay = v; + } + } + Ok(json!({"pages": pages.len()})) + }) +} + /// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule /// re-flow their objects by it. pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 879fdb368..8c8d4be23 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -35,7 +35,7 @@ fn axis(lo: f64, hi: f64, o: (f64, f64), n: (f64, f64), pin_lo: bool, pin_hi: bo fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option, vguides: &[f64], hguides: &[f64]) -> Rect { let (ox, oy, nx, ny) = ((old.x0, old.x1), (old.y0, old.y1), (new.x0, new.x1), (new.y0, new.y1)); match rule { - LiquidRule::Off => b, + LiquidRule::Off | LiquidRule::ControlledByParent => b, LiquidRule::ReCenter => b + (new.center() - old.center()), LiquidRule::Scale => { let k = (new.width() / old.width()).min(new.height() / old.height()); @@ -63,7 +63,7 @@ fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option LiquidRule { + let Some(pg) = d.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return LiquidRule::Off }; + if pg.liquid != LiquidRule::ControlledByParent { + return pg.liquid; + } + let SpreadRef::Doc(si) = sr else { return LiquidRule::Off }; + let abs = d.first_page_of_spread(si) + pi; + match d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid)) { + Some(LiquidRule::ControlledByParent) | None => LiquidRule::Off, + Some(r) => r, + } +} + fn rule_param(p: &Value, id: &str) -> Result { - serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))).map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased")) + serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))) + .map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased|controlledByParent")) } pub fn specs() -> Vec { @@ -119,7 +135,7 @@ pub fn specs() -> Vec { "Liquid Page Rule", ["Layout", "Liquid Layout"], None, - "{rule: off|scale|reCenter|guideBased|objectBased, pages?: [1-based] (default: all)}", + "{rule: off|scale|reCenter|guideBased|objectBased|controlledByParent (the parent page's rule), pages?: [1-based] (default: all)}", has_doc, |s, p| { let rule = rule_param(p, "liquid.pageRule")?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index deec4b9b1..9557e03cf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -642,3 +642,28 @@ fn page_size_keeps_objects_by_its_anchor() { assert!(s.execute("layout.selectPage", &json!({"page": 2})).is_err()); assert_eq!(s.execute("layout.selectPage", &json!({"page": 1})).unwrap()["page"], 1); } + +#[test] +fn controlled_by_parent_takes_the_parent_pages_rule() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + // The page follows its parent; the parent page re-centres. + s.execute("liquid.pageRule", &json!({"rule": "controlledByParent", "pages": [1]})).unwrap(); + s.edit(|d, _| { + std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].liquid = designcraft_doc::LiquidRule::ReCenter; + Ok(()) + }) + .unwrap(); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 600})).unwrap(); + assert_eq!(frame(&s).x0, 200.0, "re-centred: {:?}", frame(&s)); + // Without a parent (or a parent that's also controlled by its parent) nothing moves. + s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": null})).unwrap(); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 800})).unwrap(); + assert_eq!(frame(&s).x0, 200.0); + // Show Parent Page Overlay is a page option. + s.execute("layout.pageOptions", &json!({"pages": [1], "showParentOverlay": true})).unwrap(); + assert!(s.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); + assert!(s.execute("layout.pageOptions", &json!({"pages": [2], "showParentOverlay": true})).is_err()); +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be7..5bb4387de 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1963,6 +1963,7 @@ impl<'r> Importer<'r> { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, }); } let overrides: Vec> = diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index a94a93881..54db73d06 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -198,6 +198,7 @@ pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { R::ReCenter => "Re-center", R::ObjectBased => "Object-Based", R::GuideBased => "Guide-Based", + R::ControlledByParent => "Controlled by Parent", } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b3fa92336..003549a42 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -604,8 +604,18 @@ 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 { + for (pi, p) in sp.pages.iter().enumerate() { let pr = p.bounds(); + // Page tool › Show Parent Page Overlay: the parent page's size over the page. + if p.show_parent_overlay + && let SpreadRef::Doc(si) = slot.spread + && let Some((ppi, ppg)) = doc.parent_page_for(doc.first_page_of_spread(si) + pi) + && let Some(pp) = doc.parents.get(ppi).and_then(|ps| ps.pages.get(ppg)) + { + let r = rect(DRect::new(pr.x0, pr.y0, pr.x0 + pp.width, pr.y0 + pp.height)); + painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(110, 110, 230, 28)); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_rgb(110, 110, 230)), StrokeKind::Middle); + } if !app.ui.guides { continue; } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c2f1cb48e..f3a17ebf9 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -182,7 +182,9 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal_top(|ui| { let text_mode = matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some()); - if text_mode { + if app.session.tool_id() == "page" { + control_page(app, ui); + } else if text_mode { control_text(app, ui); } else { control_object(app, ui); @@ -192,6 +194,96 @@ 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 +/// 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(); + let Some(st) = app.session.active() else { return }; + let units = st.doc.settings.horizontal_units; + let Some(page) = st.selection.page.or_else(|| crate::canvas::current_page(app)) else { return }; + let Some(st) = app.session.active() else { return }; + let d = st.doc.clone(); + let Some((si, pi)) = d.page_loc(page) else { return }; + let pg = d.spreads[si].pages[pi].clone(); + let n = page + 1; + // Reference point: W/H edits keep it in place. + let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover()); + if let Some(p) = panels::properties::ref_point_proxy(ui, egui::Rect::from_center_size(r.center(), vec2(27.0, 27.0)), app.ui.ref_point) { + app.ui.ref_point = p; + } + let anchor = app.ui.ref_point.min(8); + let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3) as f64 / 2.0); + // The reference point's position: from the spine across, from the top down. + let (x, y) = (pg.x - d.spreads[si].spine_x() + fx * pg.width, fy * pg.height); + egui::Grid::new("ctl_page_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + caption(ui, crate::i18n::tr(&lang, "X:")); + // Pages don't move within their spread yet. + ui.add_enabled_ui(false, |ui| measure(ui, "cpx", Some(x), units, 64.0)); + caption(ui, crate::i18n::tr(&lang, "W:")); + if let Some(v) = measure(ui, "cpw", Some(pg.width), units, 64.0) { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": v, "anchor": anchor})); + } + ui.end_row(); + caption(ui, crate::i18n::tr(&lang, "Y:")); + ui.add_enabled_ui(false, |ui| measure(ui, "cpy", Some(y), units, 64.0)); + caption(ui, crate::i18n::tr(&lang, "H:")); + if let Some(v) = measure(ui, "cph", Some(pg.height), units, 64.0) { + let _ = app.run("layout.pageSize", json!({"pages": [n], "height": v, "anchor": anchor})); + } + ui.end_row(); + }); + vsep(ui); + // Page-size preset and orientation. + let landscape = pg.width > pg.height; + ui.vertical(|ui| { + let cur = panels::properties::preset_name(pg.width, pg.height); + egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, cur))).width(130.0).show_ui( + ui, + |ui| { + for (name, w, h) in panels::properties::PAGE_PRESETS { + if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(&lang, name))).clicked() { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor})); + } + } + }, + ); + ui.horizontal(|ui| { + for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] { + if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(&lang, tip)).clicked() && landscape != want { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": pg.height, "height": pg.width, "anchor": anchor})); + } + } + }); + }); + vsep(ui); + ui.vertical(|ui| { + caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); + let label = panels::interactive::liquid_rule_label(pg.liquid); + egui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui( + ui, + |ui| { + for (r, l) in panels::interactive::LIQUID_RULES { + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() && pg.liquid != r { + let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [n]})); + } + } + }, + ); + }); + 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})); + } + }); +} + /// A field label drawn as a tool icon (hover for its name). fn icon_caption(ui: &mut egui::Ui, icon: &str, tip: &str) { let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); @@ -830,6 +922,23 @@ mod tests { assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); } + #[test] + fn the_page_tool_has_its_own_control_panel() { + // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the + // Control panel shows the page's size, preset, orientation and liquid page rule. + let mut app = app(); + 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); + } + 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); + assert!(h.state().app.session.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); + } + #[test] fn a_narrow_window_scrolls_the_control_bar() { let w = 700.0; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 63c70d4a7..ab6b52fc2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2501,6 +2501,35 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), + ( + "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", + [ + "Überlagerung der übergeordneten Seite anzeigen", + "Afficher la superposition de la page parent", + "Mostrar superposición de página principal", + "親ページオーバーレイを表示", + "显示父页面叠加", + ], + ), + ( + "Controlled by Parent", + ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"], + ), + ("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index e0c17c88a..84b003e07 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -712,6 +712,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "استنادًا إلى الكائنات"), ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), + ("Liquid Page Rule:", "قاعدة الصفحة المرنة:"), + ("Objects Move with Page", "تحريك الكائنات مع الصفحة"), + ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), + ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), + ("US Business Card", "بطاقة عمل أمريكية"), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 3f0915aa3..8195bac61 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1298,6 +1298,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "Basato su oggetti"), ("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"), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 9e0f7a953..ead7f576d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1294,6 +1294,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "オブジェクト基準"), ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), + ("Liquid Page Rule:", "リキッドページルール:"), + ("Objects Move with Page", "オブジェクトをページと共に移動"), + ("Show Parent Page Overlay", "親ページオーバーレイを表示"), + ("Controlled by Parent", "親ページで制御"), + ("US Business Card", "US 名刺"), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index e1fbf308a..52874f549 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -711,6 +711,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "Baseado em objetos"), ("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"), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 5994dc135..68e15e629 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1295,6 +1295,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "На основі об’єктів"), ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), + ("Liquid Page Rule:", "Правило гнучкої сторінки:"), + ("Objects Move with Page", "Об’єкти рухаються зі сторінкою"), + ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), + ("Controlled by Parent", "Керується батьківською сторінкою"), + ("US Business Card", "Візитна картка (США)"), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 5b770aec8..cd36926fa 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -486,6 +486,21 @@ 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] = [ + (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. +pub fn liquid_rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { + LIQUID_RULES.iter().find(|(r, _)| *r == rule).map_or("Off", |(_, l)| l) +} + /// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's /// liquid guides, and Create Alternate Layout. pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -501,23 +516,9 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), d.page_name(page))).strong()); let mut rule = pg.liquid; egui::ComboBox::from_id_salt("liquid_rule") - .selected_text(crate::rtl::widget( - ui, - crate::i18n::tr( - &app.ui.language, - match rule { - L::Off => "Off", - L::Scale => "Scale", - L::ReCenter => "Re-center", - L::GuideBased => "Guide-based", - L::ObjectBased => "Object-based", - }, - ), - )) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, liquid_rule_label(rule)))) .show_ui(ui, |ui| { - for (r, label) in - [(L::Off, "Off"), (L::Scale, "Scale"), (L::ReCenter, "Re-center"), (L::GuideBased, "Guide-based"), (L::ObjectBased, "Object-based")] - { + for (r, label) in LIQUID_RULES { ui.selectable_value(&mut rule, r, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))); } }); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..b67be2d8e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -160,7 +160,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---------------------------------------------------------------- No Selection /// Page-size presets for the Document dropdown (portrait, points). -const PAGE_PRESETS: &[(&str, f64, f64)] = &[ +pub(crate) const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("Letter", 612.0, 792.0), ("Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), @@ -171,9 +171,10 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("A5", 419.528, 595.276), ("A6", 297.638, 419.528), ("B5", 498.898, 708.661), + ("US Business Card", 144.0, 252.0), ]; -fn preset_name(w: f64, h: f64) -> &'static str { +pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { let (a, b) = (w.min(h), w.max(h)); PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") } @@ -386,7 +387,7 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // ---------------------------------------------------------------- Objects /// The 9-point reference-point proxy; returns a newly picked point (0..8, row-major). -fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { +pub(crate) fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { let t = Tokens::get(ui.ctx()); let mut out = None; let pitch = r.width() / 3.0; From 8864307d7c8567c80c8825aa4a13a13b9202ec92 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 00:52:06 +0200 Subject: [PATCH 117/200] docs: the Page tool's Control panel in the parity notes Co-Authored-By: Claude Opus 5.5 --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 84b602d54..cbcebc37b 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 - 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. +- 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. - 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 8f71219533f325779c678835dc8c4bca5c26f446 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 01:13:23 +0200 Subject: [PATCH 118/200] feat(tools): Page tool pins and locks objects on object-based pages, on the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (6:32– 9:58) clicks an object with the Page tool on an object-based page and gets InDesign's on-canvas liquid controls: a pin outside each edge ("Object is pinned relative to the page edge. Click to release.") and a padlock on the width and height lines ("The object's width is locked. Click to let the object change width as the page changes width."). The pins and resize settings existed (liquid.object, the Liquid Layout panel) but the canvas showed none of it. - tools: on an object-based page a Page tool click selects the object with its page (layout.selectPage takes `ids`); its pins (14 px outside each edge's middle) and width/height locks (beside the centre) toggle liquid.object on click, show InDesign's tooltips on hover and a hand cursor. - canvas: LiquidPins (orange dashed width/height lines with padlocks, open when the object follows the page; hollow pins, or a line to the page edge when pinned) and Tip overlays; tooltip translations. Builds on the Page tool PR (#862). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 21 ++++- crates/engine/src/tests.rs | 56 +++++++++++++ crates/tools/src/lib.rs | 15 ++++ crates/tools/src/nav.rs | 139 ++++++++++++++++++++++++++++++- crates/tools/src/tests.rs | 2 +- crates/ui-egui/src/canvas.rs | 52 ++++++++++++ crates/ui-egui/src/i18n.rs | 60 +++++++++++++ crates/ui-egui/src/i18n/ar.rs | 18 ++++ crates/ui-egui/src/i18n/it.rs | 18 ++++ crates/ui-egui/src/i18n/ja.rs | 18 ++++ crates/ui-egui/src/i18n/pt_br.rs | 18 ++++ crates/ui-egui/src/i18n/uk.rs | 18 ++++ docs/target-app-parity.md | 2 +- 14 files changed, 429 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d9957d5d1..5c16da0c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,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** · On an object-based page the Page tool shows a selected object's liquid pins and resize locks as InDesign does: a click pins a side to the page edge or locks the width or height, with InDesign's tooltips. 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** · 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. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 4055b0809..1650d389c 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -389,20 +389,35 @@ pub fn specs() -> Vec { has_doc, page_options ), - cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { + cmd!( + noundo "layout.selectPage", + "Select Page", + [], + None, + "{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)", + has_doc, + |s, p| { let n = s.doc()?.doc.page_count(); let page = match p.get("page") { None | Some(Value::Null) => None, Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), }; + let ids: Vec = { + let d = &s.doc()?.doc; + p.get("ids") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect()) + .unwrap_or_default() + }; let st = s.doc_mut()?; if page.is_some() { - st.selection = Default::default(); + st.selection = designcraft_doc::Selection::items(ids); } st.selection.page = page; st.revision += 1; Ok(json!({"page": page.map(|p| p + 1)})) - }), + } + ), cmd!( "layout.section", "Numbering & Section Options…", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9557e03cf..af54bb942 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -667,3 +667,59 @@ fn controlled_by_parent_takes_the_parent_pages_rule() { assert!(s.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); assert!(s.execute("layout.pageOptions", &json!({"pages": [2], "showParentOverlay": true})).is_err()); } + +#[test] +fn page_tool_pins_and_locks_objects_on_object_based_pages() { + use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [256, 72, 356, 132]})).unwrap()["id"].as_u64().unwrap(); + s.execute("liquid.pageRule", &json!({"rule": "objectBased"})).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let ev = + |kind, p: designcraft_geom::Point, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, p.x, p.y) }; + let canvas = |s: &Session| { + let d = &s.doc().unwrap().doc; + let l = CanvasLayout::new(d, false); + ( + l.page_rect(d, 0).unwrap(), + l.xf(designcraft_doc::SpreadRef::Doc(0)).transform_rect_bbox(d.item(designcraft_doc::ItemId(id)).unwrap().bounds()), + ) + }; + let click = |s: &mut Session, p: designcraft_geom::Point| { + s.pointer(&ev(PointerKind::Down, p, false), v).unwrap(); + s.pointer(&ev(PointerKind::Up, p, false), v).unwrap(); + }; + let liquid = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid; + // A click on the object selects it with its page; its pins and locks show. + let (_, o) = canvas(&s); + click(&mut s, o.center()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.page, sel.items.clone()), (Some(0), vec![designcraft_doc::ItemId(id)])); + assert!( + s.overlays(v) + .iter() + .any(|o| matches!(o, designcraft_tools::Overlay::LiquidPins { pins: [false, false, false, false], resize: [true, true], .. })) + ); + // The right pin (14 px outside the right edge) and the width lock (10 px right of the centre). + click(&mut s, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y)); + click(&mut s, designcraft_geom::Point::new(o.center().x + 10.0, o.center().y)); + let l = liquid(&s).unwrap(); + assert!(l.pin_right && !l.resize_width && l.resize_height && !l.pin_left, "{l:?}"); + // Alt-drag the page 100 pt wider: the pinned, width-locked frame keeps its size by the right edge. + let (p, _) = canvas(&s); + let right = designcraft_geom::Point::new(p.x1, p.center().y); + s.pointer(&ev(PointerKind::Down, right, true), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap(); + s.pointer(&ev(PointerKind::Up, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap(); + let d = &s.doc().unwrap().doc; + let (pg, b) = (&d.spreads[0].pages[0], d.item(designcraft_doc::ItemId(id)).unwrap().bounds()); + assert_eq!(pg.width, 712.0); + assert!((b.width() - 100.0).abs() < 1e-6 && (pg.x + pg.width - b.x1 - 256.0).abs() < 1e-6, "{b:?}"); + // Hovering a pin shows its tooltip. + let (_, o) = canvas(&s); + s.pointer(&ev(PointerKind::Move, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y), false), v).unwrap(); + assert!(s.overlays(v).iter().any(|o| matches!(o, designcraft_tools::Overlay::Tip { text, .. } if text.starts_with("Object is pinned")))); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index dd09e9c25..db983fab5 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -15,6 +15,7 @@ mod frame; mod gradient; pub mod layout; mod nav; +pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX}; mod pen; mod pencil; pub mod select; @@ -322,6 +323,20 @@ pub enum Overlay { p: Point, text: String, }, + /// A tooltip near the pointer (English; the UI translates it). + Tip { + p: Point, + text: String, + }, + /// An object's liquid pins and resize locks on an object-based page (Page tool), canvas + /// coordinates: pinned sides (top, right, bottom, left) and whether width and height follow the + /// page. + LiquidPins { + object: Rect, + page: Rect, + pins: [bool; 4], + resize: [bool; 2], + }, } /// Correction from a snap, in spread coordinates for `delta`. diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 54db73d06..bd53e4cac 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -168,6 +168,75 @@ impl Tool for PlaceGun { #[derive(Default)] pub struct PageTool { drag: Option, + /// The pin or lock under the pointer (for its tooltip). + hover: Option<(LiquidControl, Point)>, +} + +/// An object's liquid control on an object-based page: a side's pin (top, right, bottom, left) or +/// the width/height lock. +#[derive(Clone, Copy, Debug, PartialEq)] +enum LiquidControl { + Pin(usize), + Width, + Height, +} + +impl LiquidControl { + /// The `liquid.object` parameter it toggles. + fn key(self) -> &'static str { + match self { + LiquidControl::Pin(0) => "pinTop", + LiquidControl::Pin(1) => "pinRight", + LiquidControl::Pin(2) => "pinBottom", + LiquidControl::Pin(_) => "pinLeft", + LiquidControl::Width => "resizeWidth", + LiquidControl::Height => "resizeHeight", + } + } + + /// InDesign's tooltip for the control in its current state. + fn tip(self, l: &designcraft_doc::ObjectLiquid) -> &'static str { + match self { + LiquidControl::Pin(i) if [l.pin_top, l.pin_right, l.pin_bottom, l.pin_left][i.min(3)] => { + "Object is pinned relative to the page edge. Click to release." + } + LiquidControl::Pin(_) => "Click to pin the object relative to the page edge.", + LiquidControl::Width if l.resize_width => "The object changes width as the page changes width. Click to lock the object width.", + LiquidControl::Width => "The object's width is locked. Click to let the object change width as the page changes width.", + LiquidControl::Height if l.resize_height => "The object changes height as the page changes height. Click to lock the object height.", + LiquidControl::Height => "The object's height is locked. Click to let the object change height as the page changes height.", + } + } + + fn value(self, l: &designcraft_doc::ObjectLiquid) -> bool { + match self { + LiquidControl::Pin(0) => l.pin_top, + LiquidControl::Pin(1) => l.pin_right, + LiquidControl::Pin(2) => l.pin_bottom, + LiquidControl::Pin(_) => l.pin_left, + LiquidControl::Width => l.resize_width, + LiquidControl::Height => l.resize_height, + } + } +} + +/// Screen pixels between an object's edge and its pin (the canvas draws them there too). +pub const PIN_OFFSET_PX: f64 = 14.0; +/// Screen pixels between an object's centre and its width/height locks. +pub const LOCK_OFFSET_PX: f64 = 10.0; + +/// Where an object's liquid controls sit (canvas): pins outside each edge's middle, the width lock +/// right of the centre, the height lock above it. +fn liquid_control_points(cx: &ToolContext, r: designcraft_geom::Rect) -> [(LiquidControl, Point); 6] { + let (c, o, k) = (r.center(), cx.tol(PIN_OFFSET_PX), cx.tol(LOCK_OFFSET_PX)); + [ + (LiquidControl::Pin(0), Point::new(c.x, r.y0 - o)), + (LiquidControl::Pin(1), Point::new(r.x1 + o, c.y)), + (LiquidControl::Pin(2), Point::new(c.x, r.y1 + o)), + (LiquidControl::Pin(3), Point::new(r.x0 - o, c.y)), + (LiquidControl::Width, Point::new(c.x + k, c.y)), + (LiquidControl::Height, Point::new(c.x, c.y - k)), + ] } /// A page handle being dragged. @@ -202,7 +271,43 @@ pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { } } +/// Whether page `abs` follows the object-based rule (its own, or its parent page's). +fn object_based(doc: &designcraft_doc::Document, abs: usize) -> bool { + use designcraft_doc::LiquidRule as R; + let Some((si, pi)) = doc.page_loc(abs) else { return false }; + match doc.spreads[si].pages[pi].liquid { + R::ObjectBased => true, + R::ControlledByParent => { + doc.parent_page_for(abs).and_then(|(ppi, ppg)| doc.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == R::ObjectBased)).unwrap_or(false) + } + _ => false, + } +} + impl PageTool { + /// The selected object's liquid settings, canvas bounds and page bounds when the Page tool + /// shows its pins: one object selected on the selected page, whose rule is object-based. + fn liquid_target( + cx: &ToolContext, + ) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, designcraft_geom::Rect, designcraft_geom::Rect)> { + let page = cx.selection.page?; + let [id] = cx.selection.items.as_slice() else { return None }; + let (si, _) = cx.doc.page_loc(page)?; + if !object_based(cx.doc, page) || cx.doc.page_of_item(*id) != Some(page) { + return None; + } + let it = cx.doc.item(*id)?; + let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); + let r = cx.layout.xf(designcraft_doc::SpreadRef::Doc(si)).transform_rect_bbox(it.bounds()); + Some((*id, l, r, cx.layout.page_rect(cx.doc, page)?)) + } + + /// The selected object's pin or lock under `p`. + fn control_at(cx: &ToolContext, p: Point) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, LiquidControl)> { + let (id, l, r, _) = Self::liquid_target(cx)?; + liquid_control_points(cx, r).into_iter().find(|(_, q)| (q.x - p.x).hypot(q.y - p.y) <= cx.tol(6.0)).map(|(c, _)| (id, l, c)) + } + /// The selected page's handle under `p`. fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> { let page = cx.selection.page?; @@ -255,12 +360,21 @@ impl Tool for PageTool { self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt }); return vec![Action::Begin("Resize Page".into())]; } - // A click selects the page under the pointer (none on the pasteboard). + // An object-based page's object: a click on a pin or lock toggles it. + if let Some((id, l, c)) = Self::control_at(cx, ev.pos) { + return vec![Action::Exec("liquid.object".into(), json!({"ids": [id.0], c.key(): !c.value(&l)}))]; + } + // A click selects the page under the pointer (none on the pasteboard), and on an + // object-based page the object clicked too. let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))); - if page == cx.selection.page { + let object = page + .filter(|p| object_based(cx.doc, *p)) + .and_then(|p| cx.hit(ev.pos).map(|(_, id)| id).filter(|id| cx.doc.page_of_item(*id) == Some(p))); + let ids: Vec = object.map(|id| id.0).into_iter().collect(); + if page == cx.selection.page && cx.selection.items.iter().map(|i| i.0).eq(ids.iter().copied()) { return vec![]; } - vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1)}))] + vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1), "ids": ids}))] } PointerKind::Drag => { let Some(d) = self.drag.as_mut() else { return vec![] }; @@ -285,7 +399,10 @@ impl Tool for PageTool { let pg = &cx.doc.spreads[si].pages[pi]; vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))] } - PointerKind::Move => vec![], + PointerKind::Move => { + self.hover = Self::control_at(cx, ev.pos).map(|(_, _, c)| (c, ev.pos)); + vec![] + } } } fn overlays(&self, cx: &ToolContext) -> Vec { @@ -294,6 +411,17 @@ impl Tool for PageTool { let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; let pg = &cx.doc.spreads[si].pages[pi]; let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }]; + if let Some((_, l, object, page)) = Self::liquid_target(cx) { + v.push(Overlay::LiquidPins { + object, + page, + pins: [l.pin_top, l.pin_right, l.pin_bottom, l.pin_left], + resize: [l.resize_width, l.resize_height], + }); + if let Some((c, p)) = self.hover { + v.push(Overlay::Tip { p, text: c.tip(&l).into() }); + } + } if let Some(d) = self.drag { v.push(Overlay::Measure { p: d.cur, @@ -307,6 +435,9 @@ impl Tool for PageTool { v } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if self.drag.is_none() && Self::control_at(cx, p).is_some() { + return Cursor::Hand; + } let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h)); match handle { Some(1 | 7) => Cursor::ResizeV, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index b2e597242..81d67c309 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -235,7 +235,7 @@ fn page_tool_selects_a_page_and_opens_page_size_on_double_click() { let (x, y) = (200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y); let mut t = create("page"); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); - assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1}))]); + assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1, "ids": []}))]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y)); assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]); // The selected page shows its handles and its liquid page rule. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 003549a42..f20e64022 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1268,11 +1268,63 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(79, 153, 255)), StrokeKind::Inside); painter.galley(r.min + vec2(4.0, 2.0), g, tok.measure_text); } + Overlay::Tip { p, text } => { + let text = crate::i18n::tr(&app.ui.language, &text).to_owned(); + let s = xf.to_screen(p) + vec2(14.0, 14.0); + let g = painter.layout(text, egui::FontId::proportional(11.0), tok.measure_text, 260.0); + let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0)); + painter.rect_filled(r, 3.0, tok.measure_bg); + painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); + } + Overlay::LiquidPins { object, page, pins, resize } => draw_liquid_pins(painter, xf.rect(object), xf.rect(page), pins, resize), Overlay::Path { .. } => {} } } } +/// An object's liquid controls on an object-based page, as InDesign draws them in orange: a +/// dashed line across the object for width and height with a padlock (closed: locked), and a pin +/// outside each edge (pinned: a line to the page edge). +fn draw_liquid_pins(painter: &egui::Painter, o: Rect, page: Rect, pins: [bool; 4], resize: [bool; 2]) { + let col = Color32::from_rgb(178, 88, 18); + let st = Stroke::new(1.5, col); + let c = o.center(); + let mids = [pos2(c.x, o.min.y), pos2(o.max.x, c.y), pos2(c.x, o.max.y), pos2(o.min.x, c.y)]; + // The width and height lines between the edges' middles. + for (a, b) in [(mids[3], mids[1]), (mids[0], mids[2])] { + dashed(painter, a, b, Stroke::new(1.0, col), 3.0, 3.0); + } + for m in mids { + painter.circle_filled(m, 3.5, col); + } + let (po, lo) = (designcraft_tools::PIN_OFFSET_PX as f32, designcraft_tools::LOCK_OFFSET_PX as f32); + let outward = [vec2(0.0, -1.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 0.0)]; + let edge = [pos2(c.x, page.min.y), pos2(page.max.x, c.y), pos2(c.x, page.max.y), pos2(page.min.x, c.y)]; + for i in 0..4 { + if pins[i] { + painter.line_segment([mids[i], edge[i]], st); + painter.circle_filled(edge[i], 4.0, col); + } else { + painter.circle_stroke(mids[i] + outward[i] * po, 4.0, st); + } + } + // Padlocks: the width lock right of the centre, the height lock above it. + for (at, locked) in [(c + vec2(lo, 0.0), !resize[0]), (c + vec2(0.0, -lo), !resize[1])] { + let body = Rect::from_center_size(at + vec2(0.0, 2.0), vec2(8.0, 6.0)); + painter.rect_filled(body.expand(1.0), 1.0, Color32::WHITE); + painter.rect_filled(body, 1.0, col); + // The shackle: over the body when locked, swung open when not. + let base = if locked { body.center().x } else { body.center().x + 3.0 }; + let pts: Vec<_> = (0..=8) + .map(|k| { + let a = std::f32::consts::PI * k as f32 / 8.0; + pos2(base - 2.6 * a.cos(), body.min.y - 3.0 * a.sin()) + }) + .collect(); + painter.add(egui::Shape::line(pts, Stroke::new(1.3, col))); + } +} + #[allow(clippy::too_many_arguments)] fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let painter = ui.painter_at(full); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ab6b52fc2..5fd1d5fd8 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2530,6 +2530,66 @@ const TABLE: &[(&str, [&str; 5])] = &[ ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"], ), ("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]), + ( + "Object is pinned relative to the page edge. Click to release.", + [ + "Das Objekt ist relativ zum Seitenrand fixiert. Zum Lösen klicken.", + "L’objet est épinglé par rapport au bord de la page. Cliquez pour le libérer.", + "El objeto está fijado respecto al borde de la página. Haga clic para soltarlo.", + "オブジェクトはページの端を基準に固定されています。クリックすると解除します。", + "对象已相对于页面边缘固定。单击可释放。", + ], + ), + ( + "Click to pin the object relative to the page edge.", + [ + "Klicken, um das Objekt relativ zum Seitenrand zu fixieren.", + "Cliquez pour épingler l’objet par rapport au bord de la page.", + "Haga clic para fijar el objeto respecto al borde de la página.", + "クリックすると、オブジェクトをページの端を基準に固定します。", + "单击可将对象相对于页面边缘固定。", + ], + ), + ( + "The object changes width as the page changes width. Click to lock the object width.", + [ + "Die Objektbreite ändert sich mit der Seitenbreite. Zum Sperren der Breite klicken.", + "La largeur de l’objet suit celle de la page. Cliquez pour verrouiller la largeur.", + "El ancho del objeto cambia con el de la página. Haga clic para bloquear el ancho.", + "オブジェクトの幅はページの幅に合わせて変わります。クリックすると幅をロックします。", + "对象宽度随页面宽度变化。单击可锁定对象宽度。", + ], + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + [ + "Die Objektbreite ist gesperrt. Klicken, damit sie sich mit der Seitenbreite ändert.", + "La largeur de l’objet est verrouillée. Cliquez pour qu’elle suive celle de la page.", + "El ancho del objeto está bloqueado. Haga clic para que cambie con el de la página.", + "オブジェクトの幅はロックされています。クリックすると、ページの幅に合わせて変わります。", + "对象宽度已锁定。单击可让其随页面宽度变化。", + ], + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + [ + "Die Objekthöhe ändert sich mit der Seitenhöhe. Zum Sperren der Höhe klicken.", + "La hauteur de l’objet suit celle de la page. Cliquez pour verrouiller la hauteur.", + "El alto del objeto cambia con el de la página. Haga clic para bloquear el alto.", + "オブジェクトの高さはページの高さに合わせて変わります。クリックすると高さをロックします。", + "对象高度随页面高度变化。单击可锁定对象高度。", + ], + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + [ + "Die Objekthöhe ist gesperrt. Klicken, damit sie sich mit der Seitenhöhe ändert.", + "La hauteur de l’objet est verrouillée. Cliquez pour qu’elle suive celle de la page.", + "El alto del objeto está bloqueado. Haga clic para que cambie con el de la página.", + "オブジェクトの高さはロックされています。クリックすると、ページの高さに合わせて変わります。", + "对象高度已锁定。单击可让其随页面高度变化。", + ], + ), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 84b003e07..b94d63e74 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -717,6 +717,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("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.", + "ارتفاع الكائن مقفل. انقر للسماح بتغيره مع ارتفاع الصفحة.", + ), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 8195bac61..eb93f1a91 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1303,6 +1303,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), ("Controlled by Parent", "Controllato dalla pagina principale"), ("US Business Card", "Biglietto da visita USA"), + ("Object is pinned relative to the page edge. Click to release.", "L’oggetto è fissato rispetto al bordo della pagina. Fai clic per sbloccarlo."), + ("Click to pin the object relative to the page edge.", "Fai clic per fissare l’oggetto rispetto al bordo della pagina."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "La larghezza dell’oggetto cambia con quella della pagina. Fai clic per bloccarla.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "La larghezza dell’oggetto è bloccata. Fai clic perché cambi con quella della pagina.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "L’altezza dell’oggetto cambia con quella della pagina. Fai clic per bloccarla.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "L’altezza dell’oggetto è bloccata. Fai clic perché cambi con quella della pagina.", + ), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ead7f576d..6d805ec39 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1299,6 +1299,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "親ページオーバーレイを表示"), ("Controlled by Parent", "親ページで制御"), ("US Business Card", "US 名刺"), + ("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.", + "オブジェクトの高さはロックされています。クリックすると、ページの高さに合わせて変わります。", + ), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 52874f549..5b61cc42e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -716,6 +716,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("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"), + ("Object is pinned relative to the page edge. Click to release.", "O objeto está fixado em relação à borda da página. Clique para liberar."), + ("Click to pin the object relative to the page edge.", "Clique para fixar o objeto em relação à borda da página."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "A largura do objeto muda com a da página. Clique para bloquear a largura.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "A largura do objeto está bloqueada. Clique para que mude com a da página.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "A altura do objeto muda com a da página. Clique para bloquear a altura.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "A altura do objeto está bloqueada. Clique para que mude com a da página.", + ), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 68e15e629..85ab20294 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1300,6 +1300,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("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.", + "Висоту об’єкта заблоковано. Клацніть, щоб вона змінювалася разом зі сторінкою.", + ), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cbcebc37b..816f5940d 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 - 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 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). - 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 9da44a042ff0d4d3eff1a5c5bb7a8dd234995960 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 01:29:44 +0200 Subject: [PATCH 119/200] feat(ui): Liquid Layout panel in InDesign's layout; Create Alternate Layout in the Layout menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (2:47, 7:24) uses the Liquid Layout panel: "Liquid Page Rule:" with "Page: n", Content › Auto-Fit, and Object Constraints › Resize with Page (Height, Width) and Pin (Top, Left, Bottom, Right), greyed until an object on an object-based page is selected. DesignCraft's panel had a "Page 1" label, the rule, six wrapped checkboxes ("Resize width", "Pin top"…) only once an object was selected, and a Create Alternate Layout form. - Liquid Layout panel: InDesign's layout and labels; the page is the one the Page tool selected (else the page in view); Auto-Fit (graphic frames) runs object.fittingOptions; the constraints apply on object-based pages (also through Controlled by Parent) and stay greyed otherwise. The page's liquid guides stay listed below for now. - Layout menu: Create Alternate Layout… (as in InDesign). - Translations for the new labels. Builds on #864 (rule list, Controlled by Parent) and #862. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 8 ++ 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/menus.rs | 1 + crates/ui-egui/src/panels/interactive.rs | 170 +++++++++++++++++------ 9 files changed, 160 insertions(+), 45 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d9957d5d1..522ea8daa 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-14** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. 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 (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** · 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** · 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. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ab6b52fc2..4d49c3152 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2498,6 +2498,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Added", ["Added", "Added", "Added", "Added", "已添加"]), ("Deleted", ["Deleted", "Deleted", "Deleted", "Deleted", "已删除"]), ("Guide-based", ["Guide-based", "Guide-based", "Guide-based", "Guide-based", "基于参考线"]), + ("Page:", ["Seite:", "Page :", "Página:", "ページ:", "页面:"]), + ("Content:", ["Inhalt:", "Contenu :", "Contenido:", "コンテンツ:", "内容:"]), + ("Object Constraints:", ["Objektbeschränkungen:", "Contraintes d’objet :", "Restricciones de objeto:", "オブジェクトの制約:", "对象约束:"]), + ( + "Resize with Page:", + ["Mit Seite skalieren:", "Redimensionner avec la page :", "Cambiar tamaño con la página:", "ページと共にサイズ変更:", "随页面调整大小:"], + ), + ("Pin:", ["Fixieren:", "Épingler :", "Fijar:", "固定:", "固定:"]), ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 84b003e07..9231209ed 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -709,6 +709,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "مضاف"), ("Deleted", "محذوف"), ("Guide-based", "استنادًا إلى الأدلة"), + ("Page:", "الصفحة:"), + ("Content:", "المحتوى:"), + ("Object Constraints:", "قيود الكائن:"), + ("Resize with Page:", "تغيير الحجم مع الصفحة:"), + ("Pin:", "تثبيت:"), ("Object-Based", "استنادًا إلى الكائنات"), ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 8195bac61..591b4904e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1295,6 +1295,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Aggiunto"), ("Deleted", "Eliminato"), ("Guide-based", "Basato su guide"), + ("Page:", "Pagina:"), + ("Content:", "Contenuto:"), + ("Object Constraints:", "Vincoli oggetto:"), + ("Resize with Page:", "Ridimensiona con la pagina:"), + ("Pin:", "Fissa:"), ("Object-Based", "Basato su oggetti"), ("Guide-Based", "Basato su guide"), ("Resize Page", "Ridimensiona pagina"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ead7f576d..c125db3f0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1291,6 +1291,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "追加済み"), ("Deleted", "削除済み"), ("Guide-based", "ガイド基準"), + ("Page:", "ページ:"), + ("Content:", "コンテンツ:"), + ("Object Constraints:", "オブジェクトの制約:"), + ("Resize with Page:", "ページと共にサイズ変更:"), + ("Pin:", "固定:"), ("Object-Based", "オブジェクト基準"), ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 52874f549..0f5d32322 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -708,6 +708,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Adicionado"), ("Deleted", "Excluído"), ("Guide-based", "Baseado em guias"), + ("Page:", "Página:"), + ("Content:", "Conteúdo:"), + ("Object Constraints:", "Restrições de objeto:"), + ("Resize with Page:", "Redimensionar com a página:"), + ("Pin:", "Fixar:"), ("Object-Based", "Baseado em objetos"), ("Guide-Based", "Baseado em guias"), ("Resize Page", "Redimensionar página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 68e15e629..c7e73bd4e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1292,6 +1292,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Додано"), ("Deleted", "Видалено"), ("Guide-based", "На основі напрямних"), + ("Page:", "Сторінка:"), + ("Content:", "Вміст:"), + ("Object Constraints:", "Обмеження об’єкта:"), + ("Resize with Page:", "Змінювати розмір зі сторінкою:"), + ("Pin:", "Закріпити:"), ("Object-Based", "На основі об’єктів"), ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..26715dcd1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -308,6 +308,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "cmd:layout.marginsAndColumns", "cmd:layout.createGuides", + "cmd:layout.createAlternate", "-", "ui:view.goToPage", "-", diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index cd36926fa..0968c9017 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -501,50 +501,104 @@ pub fn liquid_rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { LIQUID_RULES.iter().find(|(r, _)| *r == rule).map_or("Off", |(_, l)| l) } -/// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's -/// liquid guides, and Create Alternate Layout. +/// Whether page `abs` follows the object-based rule (its own, or its parent page's). +fn object_based(d: &designcraft_doc::Document, abs: usize) -> bool { + use designcraft_doc::LiquidRule as L; + let Some((si, pi)) = d.page_loc(abs) else { return false }; + match d.spreads[si].pages[pi].liquid { + L::ObjectBased => true, + L::ControlledByParent => { + d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == L::ObjectBased)).unwrap_or(false) + } + _ => false, + } +} + +/// Liquid Layout panel, laid out as InDesign's: the page's Liquid Page Rule (with "Page: n"), +/// Content › Auto-Fit, and Object Constraints › Resize with Page (Height, Width) and Pin (Top, +/// Left, Bottom, Right) for the selected object on an object-based page. The page's liquid +/// guides follow (Create Alternate Layout is in the Layout menu). pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let Some(page) = crate::canvas::current_page(app) else { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No document.")).size(11.0).color(t.text_dim)); + let lang = app.ui.language.clone(); + // The Page tool's page, the page in view, or (before the canvas lays out) the first. + let Some(page) = app.session.active().map(|s| s.selection.page.or_else(|| crate::canvas::current_page(app)).unwrap_or(0)) else { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "No document.")).size(11.0).color(t.text_dim)); return; }; let Some(d) = app.session.active().map(|d| d.doc.clone()) else { return }; let Some((si, pi)) = d.page_loc(page) else { return }; let pg = d.spreads[si].pages[pi].clone(); - use designcraft_doc::LiquidRule as L; - crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), d.page_name(page))).strong()); - let mut rule = pg.liquid; + ui.horizontal(|ui| { + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::widgets::caption(ui, &format!("{} {}", crate::i18n::tr(&lang, "Page:"), d.page_name(page))); + }); + }); egui::ComboBox::from_id_salt("liquid_rule") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, liquid_rule_label(rule)))) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, liquid_rule_label(pg.liquid)))) + .width(190.0) .show_ui(ui, |ui| { for (r, label) in LIQUID_RULES { - ui.selectable_value(&mut rule, r, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))); - } - }); - if rule != pg.liquid { - let _ = app.run("liquid.pageRule", json!({"rule": rule, "pages": [page + 1]})); - } - if rule == L::ObjectBased - && let Some(id) = app.session.active().and_then(|s| s.selection.items.first().copied()) - && let Some(it) = d.item(id) - { - ui.separator(); - let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); - let mut v = [l.resize_width, l.resize_height, l.pin_top, l.pin_bottom, l.pin_left, l.pin_right]; - let keys = ["resizeWidth", "resizeHeight", "pinTop", "pinBottom", "pinLeft", "pinRight"]; - let labels = ["Resize width", "Resize height", "Pin top", "Pin bottom", "Pin left", "Pin right"]; - ui.horizontal_wrapped(|ui| { - for ((on, key), label) in v.iter_mut().zip(keys).zip(labels) { - if ui.checkbox(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { - let _ = app.run("liquid.object", json!({key: *on})); + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked() && r != pg.liquid { + let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [page + 1]})); } } }); - } + ui.separator(); + // The selected object (one on this page). + let item = app + .session + .active() + .and_then(|s| s.selection.items.first().copied()) + .and_then(|id| d.item(id)) + .filter(|it| d.page_of_item(it.id) == Some(page)); + // Content: Auto-Fit (graphic frames). + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Content:")); + let graphic = item.and_then(|it| if let designcraft_doc::Content::Graphic(g) = &it.content { Some(g.auto_fit) } else { None }); + let mut auto = graphic.is_some_and(|f| f != designcraft_doc::Fitting::None); + ui.indent("liquid_content", |ui| { + let r = ui.add_enabled(graphic.is_some(), egui::Checkbox::new(&mut auto, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Auto-Fit")))); + if r.changed() { + let _ = app.run("object.fittingOptions", json!({"autoFit": auto})); + } + }); + // Object Constraints: for an object on an object-based page. + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Object Constraints:")); + let on = item.is_some() && object_based(&d, page); + let l = item + .filter(|_| on) + .map(|it| it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() })) + .unwrap_or_default(); + ui.indent("liquid_constraints", |ui| { + ui.add_enabled_ui(on, |ui| { + let rows: [(&str, [(&str, &str, bool); 2]); 2] = [ + ("Resize with Page:", [("Height", "resizeHeight", l.resize_height), ("Width", "resizeWidth", l.resize_width)]), + ("Pin:", [("Top", "pinTop", l.pin_top), ("Left", "pinLeft", l.pin_left)]), + ]; + for (title, boxes) in rows { + crate::widgets::caption(ui, crate::i18n::tr(&lang, title)); + let mut grid_boxes = vec![boxes.to_vec()]; + if title == "Pin:" { + grid_boxes.push(vec![("Bottom", "pinBottom", l.pin_bottom), ("Right", "pinRight", l.pin_right)]); + } + egui::Grid::new(("liquid_grid", title)).num_columns(2).min_col_width(90.0).show(ui, |ui| { + for row in grid_boxes { + for (label, key, value) in row { + let mut v = value; + if ui.checkbox(&mut v, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).changed() { + let _ = app.run("liquid.object", json!({key: v})); + } + } + ui.end_row(); + } + }); + } + }); + }); if !pg.guides.is_empty() { ui.separator(); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Liquid guides")).size(11.0).color(t.text_dim)); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Liquid guides")).size(11.0).color(t.text_dim)); for (gi, g) in pg.guides.iter().enumerate() { let mut on = g.liquid; let label = format!("{:?} at {:.1}", g.orientation, g.position); @@ -553,22 +607,6 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { } } } - ui.separator(); - let key = egui::Id::new("alt_layout"); - let (mut name, mut w, mut h): (String, f64, f64) = ui.data(|x| x.get_temp(key)).unwrap_or(("Tablet H".into(), 1024.0, 768.0)); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Create Alternate Layout")).size(11.0).color(t.text_dim)); - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut name).desired_width(90.0)); - ui.add(egui::DragValue::new(&mut w).range(1.0..=15552.0).suffix(" pt")); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "×")); - ui.add(egui::DragValue::new(&mut h).range(1.0..=15552.0).suffix(" pt")); - }); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Create"))).clicked() - && let Err(e) = app.run("layout.createAlternate", json!({"name": name, "width": w, "height": h})) - { - app.status(format!("Alternate layout: {e}")); - } - ui.data_mut(|x| x.insert_temp(key, (name, w, h))); } /// Media panel: the selected video or sound's playback options and poster. @@ -734,3 +772,45 @@ pub fn track_changes(app: &mut DesignApp, ui: &mut egui::Ui) { } }); } + +#[cfg(test)] +mod liquid_panel_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + #[test] + fn object_constraints_set_the_selected_objects_pins_and_resizing() { + 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": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(260.0, 420.0)).build_ui_state( + |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(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + liquid(app, ui); + }, + app, + ); + h.run_steps(3); + let l = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid; + // With the rule off the constraints don't apply. + h.get_by_label("Right").click(); + h.run_steps(2); + assert_eq!(l(&h), None); + h.state_mut().run("liquid.pageRule", json!({"rule": "objectBased"})).unwrap(); + h.run_steps(2); + h.get_by_label("Right").click(); + h.run_steps(2); + h.get_by_label("Width").click(); + h.run_steps(2); + let v = l(&h).unwrap(); + assert!(v.pin_right && !v.resize_width && v.resize_height, "{v:?}"); + let _ = h.get_by_label("Auto-Fit"); + } +} From db19963bad7969fb6832483c8ae1fe6c79508c09 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 01:37:14 +0200 Subject: [PATCH 120/200] feat(ui): the Page tool draws liquid guides from the rulers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (11:00– 13:30) drags guides out of the rulers with the Page tool on a guide-based page: "these guides are a little bit different... they are a dashed line, a cyan dashed line" - liquid guides, which let the objects they cross stretch with the page. DesignCraft drew liquid guides dashed and applied them, but a guide could only become liquid through the Liquid Layout panel's list: ruler drags always made ruler guides. - canvas: with the Page tool, a page guide dragged from a ruler is a liquid guide; its preview is dashed while it's dragged. - engine: guide.add takes `liquid`. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/guides.rs | 12 +++++++-- crates/ui-egui/src/canvas.rs | 48 +++++++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..92513fc39 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-14** · With the Page tool, a guide dragged from a ruler is a liquid guide (dashed), as in InDesign; guide-based pages stretch the objects it crosses. 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/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 1111cb3b0..f4fb8e3dd 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "New Guide", [], None, - "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool} → {page, index}", + "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} → {page, index}", has_doc, add ), @@ -98,6 +98,7 @@ fn add(s: &mut Session, p: &Value) -> Result { let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?; let r = super::spread_param(p, "guide.add")?; let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); + let liquid = p.get("liquid").and_then(Value::as_bool).unwrap_or(false); let d = &s.doc()?.doc; let pi = match optional_usize(p, "page", "guide.add")? { Some(pi) => pi, @@ -114,7 +115,14 @@ fn add(s: &mut Session, p: &Value) -> Result { s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?; let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?; - pg.guides.push(Guide { orientation: o, position: pos, spread: spread_guide, locked: false, layer: Some(layer), liquid: false }); + pg.guides.push(Guide { + orientation: o, + position: pos, + spread: spread_guide, + locked: false, + layer: Some(layer), + liquid: liquid && !spread_guide, + }); Ok(json!({"page": pi, "index": pg.guides.len() - 1})) }) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..eb74b4f5e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -301,7 +301,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, p.y), pos2(rect.max.x, p.y)), designcraft_doc::Orientation::Vertical => (pos2(p.x, rect.min.y), pos2(p.x, rect.max.y)), }; - painter.line_segment([a, b], Stroke::new(1.0, col)); + // A new guide from the rulers with the Page tool is a liquid guide: dashed. + if g.from.is_none() && app.session.tool_id() == "page" { + dashed(&painter, a, b, Stroke::new(1.0, col), 6.0, 3.0); + } else { + painter.line_segment([a, b], Stroke::new(1.0, col)); + } } draw_frames(app, &painter, &xf, &doc, &layout); if app.ui.hidden_characters { @@ -1760,7 +1765,10 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec let _ = app.run("guide.move", json!({"spread": r, "page": pi, "index": gi, "position": pos_of(sp)})); } (None, true, Some((r, sp, on_page))) => { - let mut params = json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none()}); + // The Page tool draws liquid guides from the rulers. + let liquid = app.session.tool_id() == "page" && on_page.is_some(); + let mut params = + json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none(), "liquid": liquid}); if let Some(pi) = on_page { params["page"] = json!(pi); } @@ -1791,6 +1799,42 @@ mod tests { h } + /// A primary-button drag from `a` to `b` over a few frames. + fn drag(h: &mut Harness<'_, DesignApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.step(); + h.event(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.step(); + } + h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + } + + #[test] + fn the_page_tool_draws_liquid_guides_from_the_rulers() { + // Chris Cicchino's Page Tool and Liquid Layout video (11:00): with the Page tool, a guide + // dragged from the ruler is a liquid guide (dashed). + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.session.set_tool("page"); + let mut h = harness(app); + let r = h.state().canvas_rect.unwrap(); + let guides = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].pages[0].guides.clone(); + drag(&mut h, pos2(r.center().x, r.min.y - 8.0), r.center()); + let g = guides(&h); + assert_eq!(g.len(), 1, "{g:?}"); + assert!(g[0].liquid && g[0].orientation == designcraft_doc::Orientation::Horizontal); + // With the Selection tool it's a ruler guide. + h.state_mut().session.set_tool("selection"); + drag(&mut h, pos2(r.center().x, r.min.y - 8.0), r.center() + vec2(0.0, 40.0)); + let g = guides(&h); + assert_eq!(g.len(), 2); + assert!(!g[1].liquid); + } + fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) { h.hover_at(p); h.step(); From 3939b825d3a5ff6709079b6e6fcdf6a8605dd3d3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 02:02:16 +0200 Subject: [PATCH 121/200] feat(ui): Pages panel duplicates pages by Alt-drag and the New Page button; Edit page size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:42, 2:06) holds Option and drags a page in the Pages panel to copy it, drags a page onto the New Page button to duplicate it ("click and drag onto my plus sign I can duplicate that"), and resizes pages; InDesign's panel also has an Edit page size button. DesignCraft's panel only moved pages on drag, had no Edit page size button, and duplicating a page gave the copy the size of the page before it (not its own) without its liquid rule or guides. A page of its own size also drew its thumbnail at the document's scale, over the panel's footer. - Pages panel: Alt-drag copies the page to where it's dropped; a page dropped on Create new page is duplicated at the end of the document; Edit page size lists the presets (in the page's orientation) and Custom Page Size…; InDesign's tooltips (Create new page, Delete selected pages). - Thumbnails share one scale (the document's page size, smaller when a taller page would pass 1.8 times the usual height); rows fit their spread's tallest page. - engine: layout.pages.duplicateSpread copies the pages' size, margins, columns, guides and liquid rule, takes `atEnd`, and returns the copy's first page. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 53 ++++++++---- crates/engine/src/tests.rs | 26 ++++++ crates/ui-egui/src/i18n.rs | 16 ++++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ja.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 4 + crates/ui-egui/src/panels/pages.rs | 106 +++++++++++++++++++++++- crates/ui-egui/src/panels/properties.rs | 4 +- 11 files changed, 205 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..cf4d92cff 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-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. 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/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..663e10791 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -135,21 +135,44 @@ pub fn specs() -> Vec { ok() }) }), - cmd!("layout.pages.duplicateSpread", "Duplicate Spread", ["Layout", "Pages"], None, "{spread}", has_doc, |s, p| { - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - s.edit(|d, _| { - let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; - let after = 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)?; - // Copy items onto the new spread(s) at the same positions. - let src = d.clone(); - 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() - }) - }), + cmd!( + "layout.pages.duplicateSpread", + "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", + 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); + s.edit(|d, _| { + let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; + 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)?; + // The copies are the pages they copy: size, margins, columns, guides, liquid rule. + for (k, src) in sp.pages.iter().enumerate() { + let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue }; + let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]); + let pg = &mut nsp.pages[npi]; + let dx = pg.x - src.x; + (pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = + (src.width, src.height, src.margins, src.columns.clone(), src.liquid); + pg.guides = src.guides.clone(); + for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { + g.position += dx; + } + nsp.relayout(); + } + // Copy items onto the new spread(s) at the same positions. + let src = d.clone(); + 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})) + }) + } + ), cmd!( "layout.pages.applyParent", "Apply Parent to Pages…", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed098..f896ab3cd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,29 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn duplicating_a_page_keeps_its_own_size_rule_guides_and_objects() { + // Chris Cicchino's Page Tool video (2:06): the letterhead page is duplicated, then resized. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + s.execute("layout.pageSize", &json!({"pages": [2], "width": 612, "height": 792})).unwrap(); + s.execute("liquid.pageRule", &json!({"rule": "scale", "pages": [2]})).unwrap(); + s.execute("guide.add", &json!({"spread": 1, "page": 0, "orientation": "vertical", "position": 300})).unwrap(); + s.execute("frame.create", &json!({"spread": 1, "rect": [100, 100, 200, 200]})).unwrap(); + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 1})).unwrap(); + assert_eq!(r["page"], 2); + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(2).unwrap(); + let pg = &d.spreads[si].pages[pi]; + assert_eq!((pg.width, pg.height, pg.liquid), (612.0, 792.0, designcraft_doc::LiquidRule::Scale)); + assert_eq!(pg.guides.len(), 1); + assert_eq!(d.spreads[si].items.len(), 1); + // At the end of the document (the New Page button): the business card, not the last page's size. + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "atEnd": true})).unwrap(); + assert_eq!(r["page"], 3); + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(3).unwrap(); + assert_eq!((d.spreads[si].pages[pi].width, d.spreads[si].pages[pi].height), (252.0, 144.0)); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..93728dde5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -612,6 +612,22 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Edit Page", ["Edit Page", "Edit Page", "Edit Page", "Edit Page", "编辑页面"]), ("Import File", ["Import File", "Import File", "Import File", "Import File", "导入文件"]), ("Insert Page", ["Insert Page", "Insert Page", "Insert Page", "Insert Page", "插入页面"]), + ( + "Delete selected pages", + ["Ausgewählte Seiten löschen", "Supprimer les pages sélectionnées", "Eliminar páginas seleccionadas", "選択したページを削除", "删除选定页面"], + ), + ("Create new page", ["Neue Seite erstellen", "Créer une page", "Crear página nueva", "新規ページを作成", "新建页面"]), + ("Edit page size", ["Seitenformat bearbeiten", "Modifier le format de page", "Editar tamaño de página", "ページサイズを編集", "编辑页面大小"]), + ( + "Custom Page Size…", + [ + "Benutzerdefiniertes Seitenformat …", + "Format de page personnalisé…", + "Tamaño de página personalizado…", + "カスタムページサイズ…", + "自定页面大小…", + ], + ), ("Hide Bar", ["Hide Bar", "Hide Bar", "Hide Bar", "Hide Bar", "隐藏栏"]), ("Properties Panel", ["Properties Panel", "Properties Panel", "Properties Panel", "Properties Panel", "属性面板"]), ("New Workspace…", ["New Workspace…", "New Workspace…", "New Workspace…", "New Workspace…", "新建工作区…"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..4373051b8 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -270,6 +270,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Quick Actions", "الإجراءات السريعة"), ("Import File", "استيراد ملف"), ("Insert Page", "إدراج صفحة"), + ("Delete selected pages", "حذف الصفحات المحددة"), + ("Create new page", "إنشاء صفحة جديدة"), + ("Edit page size", "تحرير حجم الصفحة"), + ("Custom Page Size…", "حجم صفحة مخصص…"), ("Edit Page", "تحرير الصفحة"), ("Adjust Layout", "ضبط التخطيط"), ("Smart Guides", "أدلة ذكية"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f6..f86e1b196 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -363,6 +363,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "Modifica pagina"), ("Import File", "Importa file"), ("Insert Page", "Inserisci pagina"), + ("Delete selected pages", "Elimina pagine selezionate"), + ("Create new page", "Crea nuova pagina"), + ("Edit page size", "Modifica dimensioni pagina"), + ("Custom Page Size…", "Dimensioni pagina personalizzate…"), ("Hide Bar", "Nascondi barra"), ("Properties Panel", "Pannello Proprietà"), ("New Workspace…", "Nuova area di lavoro…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c0..53833dd01 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -365,6 +365,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "ページを編集"), ("Import File", "ファイルを読み込み"), ("Insert Page", "ページを挿入"), + ("Delete selected pages", "選択したページを削除"), + ("Create new page", "新規ページを作成"), + ("Edit page size", "ページサイズを編集"), + ("Custom Page Size…", "カスタムページサイズ…"), ("Hide Bar", "バーを隠す"), ("Properties Panel", "プロパティパネル"), ("New Workspace…", "新規ワークスペース…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20d..25229c806 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -271,6 +271,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Quick Actions", "Ações rápidas"), ("Import File", "Importar arquivo"), ("Insert Page", "Inserir página"), + ("Delete selected pages", "Excluir páginas selecionadas"), + ("Create new page", "Criar nova página"), + ("Edit page size", "Editar tamanho da página"), + ("Custom Page Size…", "Tamanho de página personalizado…"), ("Edit Page", "Editar página"), ("Adjust Layout", "Ajustar layout"), ("Smart Guides", "Guias inteligentes"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb35..7adad1644 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -366,6 +366,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "Редагувати сторінку"), ("Import File", "Імпортувати файл"), ("Insert Page", "Вставити сторінку"), + ("Delete selected pages", "Видалити вибрані сторінки"), + ("Create new page", "Створити нову сторінку"), + ("Edit page size", "Змінити розмір сторінки"), + ("Custom Page Size…", "Власний розмір сторінки…"), ("Hide Bar", "Сховати панель"), ("Properties Panel", "Панель властивостей"), ("New Workspace…", "Нове робоче середовище…"), diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ae323225b..d70b3427e 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -114,11 +114,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let cur_spread = doc.page_loc(cur).map(|l| l.0); let center = ui.available_width() / 2.0; let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); + let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); for (si, sp) in doc.spreads.iter().enumerate() { let first = doc.first_page_of_spread(si); - let scale = THUMB_H / doc.settings.page_height.max(1.0) as f32; + // One scale for the whole document (pages of their own size show in proportion): the + // document's page size at the usual height, unless a taller page would pass 1.8 times it. + // Each row is as tall as its spread's tallest page. + let scale = (THUMB_H / doc.settings.page_height.max(1.0) as f32).min(1.8 * THUMB_H / tallest as f32); + let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(THUMB_H * 0.25); let spine = sp.spine_x() as f32; - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover()); + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover()); let top = row.min.y + 8.0; // Marker above the current spread. if cur_spread == Some(si) && !editing_parents { @@ -197,15 +202,55 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { .color(t.text_dim), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Page")).clicked() { + if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete selected pages")).clicked() { let _ = app.run("layout.pages.delete", json!({"pages": [cur]})); } - if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "Insert Page")).clicked() { + // Create new page; a page dropped on it is duplicated at the end of the document. + let plus = icons::button(ui, "plus", 20.0, dragging, crate::i18n::tr(&app.ui.language, "Create new page")); + if let Some(payload) = plus.dnd_release_payload::() { + if let PageDrag::Page(from) = *payload + && let Some((si, _)) = doc.page_loc(from) + { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); + } + } else if plus.clicked() { let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1})); } + // Edit page size: InDesign's presets for the current page, or Custom Page Size…. + let size = icons::button(ui, "panel-pages", 20.0, false, crate::i18n::tr(&app.ui.language, "Edit page size")); + egui::Popup::menu(&size).show(|ui| { + let Some((si, pi)) = doc.page_loc(cur) else { return }; + let pg = &doc.spreads[si].pages[pi]; + let landscape = pg.width > pg.height; + let current = crate::panels::properties::preset_name(pg.width, pg.height); + for (name, w, h) in crate::panels::properties::PAGE_PRESETS { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + if ui.selectable_label(current == *name, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, name))).clicked() { + let _ = app.run("layout.pageSize", json!({"pages": [cur + 1], "width": w, "height": h})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Custom Page Size…"))).clicked() { + app.ui.dialog = + Some(crate::dialogs::Dialog::new("cmd:layout.pageSize", json!({"pages": [cur + 1], "width": pg.width, "height": pg.height}))); + ui.close(); + } + }); }); }); + // Alt-drag (InDesign's Option-drag) copies the page to where it's dropped. + let copy = ui.input(|i| i.modifiers.alt); 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 from != to => { let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); } @@ -289,3 +334,56 @@ fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside); painter.galley(r.min + vec2(5.0, 3.0), g, t.text_strong); } + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(280.0, 520.0)).build_ui_state( + |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(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + /// Release a dragged page over `at`. + fn drop_page(h: &mut Harness<'_, DesignApp>, page: usize, at: egui::Pos2, alt: bool) { + let modifiers = egui::Modifiers { alt, ..Default::default() }; + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers }); + egui::DragAndDrop::set_payload(&h.ctx, PageDrag::Page(page)); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(3); + } + + #[test] + fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() { + // Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that". + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + app.run("layout.pages.insert", json!({"count": 1})).unwrap(); + app.run("layout.pageSize", json!({"pages": [2], "width": 612, "height": 792})).unwrap(); + let mut h = harness(app); + let plus = h.get_by_label("Create new page").rect().center(); + drop_page(&mut h, 1, plus, false); + let d = h.state().session.doc().unwrap().doc.clone(); + assert_eq!(d.page_count(), 3); + let (si, pi) = d.page_loc(2).unwrap(); + assert_eq!(d.spreads[si].pages[pi].width, 612.0, "a copy of the letterhead"); + let _ = h.get_by_label("Edit page size"); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..89e12890c 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -160,7 +160,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---------------------------------------------------------------- No Selection /// Page-size presets for the Document dropdown (portrait, points). -const PAGE_PRESETS: &[(&str, f64, f64)] = &[ +pub(crate) const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("Letter", 612.0, 792.0), ("Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), @@ -173,7 +173,7 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("B5", 498.898, 708.661), ]; -fn preset_name(w: f64, h: f64) -> &'static str { +pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { let (a, b) = (w.min(h), w.max(h)); PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") } From 8156e575bc1b403e6c62054fca132e4a35ff927e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 02:15:27 +0200 Subject: [PATCH 122/200] feat(ui): Pages panel menu in InDesign's order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pages panel menu in Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:47) reads: Insert Pages…, Move Pages…, Duplicate Spread, Duplicate Spread at End of Document, Delete Spread, … New Parent…, Apply Parent to Pages…, Override All Parent Page Items, Parent Pages ▸, Create Alternate Layout…, Numbering & Section Options…, Allow Selected Spread to Shuffle. DesignCraft's menu (and the page thumbnails' context menu) had Insert Page After, Delete Page, Duplicate Spread, Add to Previous Spread, Allow Spread to Shuffle and the parent items in another order. - Pages panel menu and page context menu in InDesign's order and labels: Insert Pages… and Move Pages… open their dialogs for the page; Delete Spread for a spread of several pages; Parent Pages ▸ holds Remove All Local Overrides and Detach All Objects from Parent; Create Alternate Layout… and Numbering & Section Options… open theirs. Add to Previous Spread stays last (InDesign drags pages into spreads instead). - Translations (pt-BR gains the existing items it lacked). Builds on #869 (Duplicate Spread at End of Document). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 22 ++++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ja.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 12 +++ crates/ui-egui/src/i18n/uk.rs | 4 + crates/ui-egui/src/panel_docking_tests.rs | 2 +- crates/ui-egui/src/panels/pages.rs | 119 +++++++++++++++++----- 9 files changed, 142 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cf4d92cff..c091b6658 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-14** · The Pages panel menu follows InDesign's order: Insert Pages…, Move Pages…, Duplicate Spread (and at End of Document), Delete Page/Spread, New Parent…, Apply Parent to Pages, Override All Parent Page Items, Parent Pages, Create Alternate Layout…, Numbering & Section Options…, Allow Selected Spread to Shuffle. No readiness percentage change. - **2026-10-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. 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. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 93728dde5..7ab2e23f3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -296,6 +296,28 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Document Setup", ["Document Setup", "Document Setup", "Document Setup", "Document Setup", "文档设置"]), ("Drop Shadow", ["Drop Shadow", "Drop Shadow", "Drop Shadow", "Drop Shadow", "投影"]), ("Duplicate Spread", ["Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "复制跨页"]), + ( + "Duplicate Spread at End of Document", + [ + "Druckbogen am Dokumentende duplizieren", + "Dupliquer la planche à la fin du document", + "Duplicar pliego al final del documento", + "ドキュメントの最後にスプレッドを複製", + "在文档末尾复制跨页", + ], + ), + ("Delete Spread", ["Druckbogen löschen", "Supprimer la planche", "Eliminar pliego", "スプレッドを削除", "删除跨页"]), + ("Parent Pages", ["Übergeordnete Seiten", "Pages parents", "Páginas principales", "親ページ", "父页面"]), + ( + "Apply Parent to Pages", + [ + "Übergeordnete Seite auf Seiten anwenden", + "Appliquer le parent aux pages", + "Aplicar principal a páginas", + "親ページを適用", + "将父页面应用于页面", + ], + ), ("Email", ["Email", "Email", "Email", "Email", "电子邮件"]), ("Embed", ["Embed", "Embed", "Embed", "Embed", "嵌入"]), ("Export PDF", ["PDF exportieren", "Exporter en PDF", "Exportar PDF", "PDF を書き出し", "导出PDF"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 4373051b8..75dd0fea0 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -864,6 +864,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Grid", "شبكة المستند"), ("Document Recovery Data", "بيانات استرداد المستند"), ("Duplicate Spread", "تكرار الصفحات المتقابلة"), + ("Duplicate Spread at End of Document", "تكرار الانتشار في نهاية المستند"), + ("Delete Spread", "حذف الانتشار"), + ("Parent Pages", "الصفحات الأصل"), + ("Apply Parent to Pages", "تطبيق الأصل على الصفحات"), ("Email", "بريد إلكتروني"), ("Embed", "تضمين"), ("Fast", "سريع"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index f86e1b196..735bd38c5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -177,6 +177,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "Impostazione documento"), ("Drop Shadow", "Ombra esterna"), ("Duplicate Spread", "Duplica doppia pagina"), + ("Duplicate Spread at End of Document", "Duplica pagine affiancate alla fine del documento"), + ("Delete Spread", "Elimina pagine affiancate"), + ("Parent Pages", "Pagine principali"), + ("Apply Parent to Pages", "Applica pagina principale alle pagine"), ("Email", "E-mail"), ("Embed", "Incorpora"), ("Export XML…", "Esporta XML…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 53833dd01..87c0aea64 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -179,6 +179,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "ドキュメント設定"), ("Drop Shadow", "ドロップシャドウ"), ("Duplicate Spread", "スプレッドを複製"), + ("Duplicate Spread at End of Document", "ドキュメントの最後にスプレッドを複製"), + ("Delete Spread", "スプレッドを削除"), + ("Parent Pages", "親ページ"), ("Email", "メール"), ("Embed", "埋め込み"), ("Export XML…", "XML を書き出し…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 25229c806..d4456e5e2 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -866,6 +866,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Grid", "Grade do documento"), ("Document Recovery Data", "Dados de recuperação do documento"), ("Duplicate Spread", "Duplicar página dupla"), + ("Insert Pages…", "Inserir páginas…"), + ("Move Pages…", "Mover páginas…"), + ("New Parent…", "Nova página principal…"), + ("Override All Parent Page Items", "Substituir todos os itens da página principal"), + ("Remove All Local Overrides", "Remover todas as substituições locais"), + ("Detach All Objects from Parent", "Desanexar todos os objetos da página principal"), + ("Create Alternate Layout…", "Criar layout alternativo…"), + ("Allow Selected Spread to Shuffle", "Permitir que a página espelhada selecionada seja reorganizada"), + ("Duplicate Spread at End of Document", "Duplicar página espelhada no final do documento"), + ("Delete Spread", "Excluir página espelhada"), + ("Parent Pages", "Páginas principais"), + ("Apply Parent to Pages", "Aplicar página principal às páginas"), ("Email", "E-mail"), ("Embed", "Embutir"), ("Fast", "Rápido"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 7adad1644..4cc572cd9 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -180,6 +180,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "Параметри документа"), ("Drop Shadow", "Тінь"), ("Duplicate Spread", "Дублювати розворот"), + ("Duplicate Spread at End of Document", "Дублювати розворот у кінці документа"), + ("Delete Spread", "Видалити розворот"), + ("Parent Pages", "Батьківські сторінки"), + ("Apply Parent to Pages", "Застосувати батьківську до сторінок"), ("Email", "Електронна пошта"), ("Embed", "Вбудувати"), ("Export XML…", "Експортувати XML…"), diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index de08e3cf5..56b906211 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -617,7 +617,7 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { } h.run_steps(2); assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); - h.get_by_label("Insert Page After").click(); + h.get_by_label("Duplicate Spread").click(); h.run_steps(3); assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); assert!(!egui::Popup::is_any_open(&h.ctx)); diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d70b3427e..dfc69dfff 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -271,47 +271,39 @@ pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) { } fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Insert Page After"))).clicked() { - let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1})); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_owned(); + let pages_in_spread = doc.spreads.get(si).map_or(1, |sp| sp.pages.len()); + let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, tr(label))).clicked(); + // InDesign's order: pages, parents, layouts and numbering, shuffling. + if item(ui, "Insert Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": abs, "count": 1}))); ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Page"))).clicked() { - let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + if item(ui, "Move Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.move", json!({"from": abs, "to": abs}))); ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Duplicate Spread"))).clicked() { + if item(ui, "Duplicate Spread") { let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si})); ui.close(); } - if si > 0 - && ui - .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add to Previous Spread"))) - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Spreads of up to 10 pages")); - }) - .clicked() - { - let _ = app.run("layout.pages.toSpread", json!({"page": abs + 1, "spread": si - 1})); + if item(ui, "Duplicate Spread at End of Document") { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); ui.close(); } - let mut shuffle = doc.spreads.get(si).is_none_or(|sp| sp.allow_shuffle); - if ui.checkbox(&mut shuffle, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Allow Spread to Shuffle"))).clicked() { - let _ = app.run("layout.spreadShuffle", json!({"spread": si, "allow": shuffle})); + if item(ui, if pages_in_spread > 1 { "Delete Spread" } else { "Delete Page" }) { + let first = doc.first_page_of_spread(si); + let _ = app.run("layout.pages.delete", json!({"pages": (first..first + pages_in_spread).collect::>()})); ui.close(); } ui.separator(); - for (id, label) in [ - ("layout.overrideParentItems", "Override All Parent Page Items"), - ("layout.removeOverrides", "Remove All Local Overrides"), - ("layout.detachAll", "Detach All Objects from Parent"), - ] { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run(id, json!({"page": abs})); - ui.close(); - } + if item(ui, "New Parent…") { + crate::menus::activate(app, "layout.parents.new", &serde_json::Value::Null); + ui.close(); } - crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Parent")), |ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "[None]"))).clicked() { + crate::menus::menu_button(ui, crate::rtl::widget(ui, tr("Apply Parent to Pages")), |ui| { + if ui.button(crate::rtl::widget(ui, tr("[None]"))).clicked() { let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null})); ui.close(); } @@ -323,6 +315,45 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu } } }); + if item(ui, "Override All Parent Page Items") { + let _ = app.run("layout.overrideParentItems", json!({"page": abs})); + ui.close(); + } + crate::menus::menu_button(ui, crate::rtl::widget(ui, tr("Parent Pages")), |ui| { + for (id, label) in [("layout.removeOverrides", "Remove All Local Overrides"), ("layout.detachAll", "Detach All Objects from Parent")] { + if ui.button(crate::rtl::widget(ui, tr(label))).clicked() { + let _ = app.run(id, json!({"page": abs})); + ui.close(); + } + } + }); + ui.separator(); + if item(ui, "Create Alternate Layout…") { + crate::menus::activate(app, "layout.createAlternate", &serde_json::Value::Null); + ui.close(); + } + if item(ui, "Numbering & Section Options…") { + crate::menus::activate(app, "layout.section", &serde_json::Value::Null); + ui.close(); + } + ui.separator(); + let mut shuffle = doc.spreads.get(si).is_none_or(|sp| sp.allow_shuffle); + if ui.checkbox(&mut shuffle, crate::rtl::widget(ui, tr("Allow Selected Spread to Shuffle"))).clicked() { + let _ = app.run("layout.spreadShuffle", json!({"spread": si, "allow": shuffle})); + ui.close(); + } + // Not in InDesign's menu (it drags pages into spreads): kept for spreads of up to 10 pages. + if si > 0 + && ui + .button(crate::rtl::widget(ui, tr("Add to Previous Spread"))) + .on_hover_ui(|ui| { + crate::rtl::label(ui, tr("Spreads of up to 10 pages")); + }) + .clicked() + { + let _ = app.run("layout.pages.toSpread", json!({"page": abs + 1, "spread": si - 1})); + ui.close(); + } } /// A small label following the pointer while dragging. @@ -370,6 +401,38 @@ 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(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |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(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + let doc = app.session.doc().unwrap().doc.clone(); + page_menu(app, ui, &doc, 0, 0); + }, + app, + ); + h.run_steps(3); + // 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() + .map(|l| h.get_by_label(l).rect().center().y) + .collect(); + assert!(ys.windows(2).all(|w| w[0] < w[1]), "{ys:?}"); + h.get_by_label("Duplicate Spread at End of Document").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); + h.get_by_label("Insert Pages…").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "cmd:layout.pages.insert")); + } + #[test] fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() { // Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that". From 06ed9bb4f547b2752837d9eae111fe865e8ce9be Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 19:37:52 +0000 Subject: [PATCH 123/200] 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 124/200] 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 125/200] 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 126/200] 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 127/200] 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 128/200] 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 129/200] 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 130/200] 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 131/200] 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 132/200] 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 133/200] 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 134/200] 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 135/200] 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 136/200] 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 137/200] 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 138/200] 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 139/200] 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 140/200] 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 141/200] =?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 142/200] 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 143/200] 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 144/200] 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 11a51d5b665aec8d6e675bd107a8a01514f10d00 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 09:06:57 -0400 Subject: [PATCH 145/200] Release 0.8.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 40 ++++++++++++++++++++-------------------- Cargo.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81e7a5c6b..e77a81be4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.7.1" +version = "0.8.0" dependencies = [ "arboard", "designcraft-engine", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -921,7 +921,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.7.1" +version = "0.8.0" dependencies = [ "kurbo", "moxcms", @@ -933,7 +933,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -951,7 +951,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-geom", @@ -963,7 +963,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.7.1" +version = "0.8.0" dependencies = [ "caseless", "craft-print", @@ -998,7 +998,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1007,7 +1007,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.7.1" +version = "0.8.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -1019,7 +1019,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-doc", "serde", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.7.1" +version = "0.8.0" dependencies = [ "flo_curves", "kurbo", @@ -1043,7 +1043,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1056,7 +1056,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-fonts", @@ -1067,7 +1067,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1102,7 +1102,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1120,7 +1120,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-doc", "quick-xml 0.38.4", @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1141,7 +1141,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.7.1" +version = "0.8.0" dependencies = [ "craft-ui", "designcraft-color", @@ -1169,7 +1169,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5697,7 +5697,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.7.1" +version = "0.8.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 9ce011b57..e76609e08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.7.1" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From c7b3ee4f45983d2f2b45d5a0133fe77117e34c85 Mon Sep 17 00:00:00 2001 From: Govinda Roy Date: Wed, 14 Oct 2026 19:39:37 +0600 Subject: [PATCH 146/200] 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 147/200] 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 148/200] 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 149/200] 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: Wed, 14 Oct 2026 15:41:04 +0100 Subject: [PATCH 150/200] 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 151/200] 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 152/200] 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 153/200] 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 154/200] 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 155/200] 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 156/200] 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 157/200] 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 158/200] 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 159/200] 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 160/200] 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 161/200] 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 162/200] 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 8648cb0020c18ad7f267787057c5f3ee6c30619b Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 17:08:35 +0000 Subject: [PATCH 163/200] Place: a dropped file lands on the spread under the pointer (#931) A file dropped on the window is placed on the spread under the pointer, or the spread under the view centre when the pointer isn't over the canvas, a parent spread included while parents are being edited: a graphic's top left goes to that point, text into a new frame on the document page there. Before, every dropped file went to the first spread. --- crates/ui-egui/src/canvas.rs | 10 ++++++++ crates/ui-egui/src/lib.rs | 47 ++++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5d8218b77..0564d1b74 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -150,6 +150,16 @@ pub fn view_centre(app: &DesignApp) -> Option<(SpreadRef, Point)> { Some((slot.spread, slot.to_spread(c))) } +/// Where a file dropped on the window lands: the spread under the pointer, or under the view +/// centre when the pointer isn't over the canvas, and that point in spread coordinates. +pub fn drop_point(app: &DesignApp, screen: Option) -> Option<(SpreadRef, Point)> { + let rect = app.canvas_rect?; + let Some(pos) = screen.filter(|p| rect.contains(*p)) else { return view_centre(app) }; + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + layout.spread_at(Xf::new(rect, app.view()?).to_canvas(pos)) +} + /// Absolute page index under the view centre. pub fn current_page(app: &DesignApp) -> Option { let st = app.session.active()?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 22f35801f..928ff210e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -934,10 +934,11 @@ impl DesignApp { menus::shortcuts(self, ctx); } #[cfg(not(target_arch = "wasm32"))] - for f in ctx.input(|i| i.raw.dropped_files.clone()) { + let (dropped, pointer) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.hover_pos())); + for f in dropped { let p = f.path().to_string_lossy().to_string(); if !p.is_empty() { - let _ = self.open_dropped(&p); + let _ = self.open_dropped_at(&p, pointer); } } self.handle_quit(ctx); @@ -1087,10 +1088,29 @@ impl DesignApp { } } - /// A file dropped on the window: documents open, anything else is placed. + /// A file dropped on the window: documents open, anything else is placed on the spread in + /// view (a parent spread while parents are being edited). pub fn open_dropped(&mut self, path: &str) -> Result { - let cmd = if opens_as_document(path) { "file.open" } else { "file.place" }; - self.open_file(cmd, json!({"path": path})) + self.open_dropped_at(path, None) + } + + /// [`Self::open_dropped`] at the pointer: a graphic's top left lands where the file was + /// dropped on the canvas, text goes to a new frame on the page there. + pub fn open_dropped_at(&mut self, path: &str, pointer: Option) -> Result { + if opens_as_document(path) { + return self.open_file("file.open", json!({"path": path})); + } + let mut params = json!({"path": path}); + if let (Some((sr, at)), Some(o)) = (canvas::drop_point(self, pointer), params.as_object_mut()) { + o.insert("spread".into(), serde_json::to_value(sr).unwrap_or(Value::Null)); + o.insert("x".into(), json!(at.x)); + o.insert("y".into(), json!(at.y)); + if let (designcraft_doc::SpreadRef::Doc(si), Some(st)) = (sr, self.session.active()) { + let pi = st.doc.spreads.get(si).and_then(|s| s.page_at_x(at.x)).unwrap_or(0); + o.insert("page".into(), json!(st.doc.first_page_of_spread(si) + pi + 1)); + } + } + self.open_file("file.place", params) } /// File › Place: the file fills the selected empty or graphic frame; with none, an image loads @@ -2035,6 +2055,23 @@ mod tests { assert_eq!(alert(&app), ("Can't Place the File".to_string(), e)); } + #[test] + fn a_file_dropped_while_a_parent_is_open_lands_on_the_parent() { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"pages": 3})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + let _ = app.view_mut(); + let path = std::env::temp_dir().join(format!("designcraft-ui-{}-drop.png", std::process::id())); + std::fs::write(&path, designcraft_render::Rendered { width: 10, height: 10, pixels: vec![200; 400] }.to_png()).unwrap(); + let r = app.open_dropped(&path.to_string_lossy()); + let _ = std::fs::remove_file(&path); + r.unwrap(); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.parents[0].items.len(), 1, "the graphic is on the parent spread"); + assert!(d.spreads.iter().all(|s| s.items.is_empty()), "no document page gets it"); + } + #[test] fn file_open_failures_show_an_alert() { let path = unreadable_file("open.designcraft"); From f1118d826e4d3bd9cdc5bcafa3e41f8a42abf256 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 17:15:50 +0000 Subject: [PATCH 164/200] Place: the dropped-file loop stays out of the web build --- crates/ui-egui/src/lib.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 928ff210e..c1ca016bd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -934,11 +934,13 @@ impl DesignApp { menus::shortcuts(self, ctx); } #[cfg(not(target_arch = "wasm32"))] - let (dropped, pointer) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.hover_pos())); - for f in dropped { - let p = f.path().to_string_lossy().to_string(); - if !p.is_empty() { - let _ = self.open_dropped_at(&p, pointer); + { + let (dropped, pointer) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.hover_pos())); + for f in dropped { + let p = f.path().to_string_lossy().to_string(); + if !p.is_empty() { + let _ = self.open_dropped_at(&p, pointer); + } } } self.handle_quit(ctx); From 11b1566c099aff00974010a756bc8204c240a106 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 19:22:54 +0200 Subject: [PATCH 165/200] 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 166/200] 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 167/200] 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 168/200] 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 169/200] 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 4516989e51b7f688e3f7e7dbff4a1ba0e354109a Mon Sep 17 00:00:00 2001 From: zydvc26s67-web Date: Wed, 14 Oct 2026 12:05:35 -0700 Subject: [PATCH 170/200] 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 171/200] 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 172/200] 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 173/200] 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 174/200] 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 175/200] 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 176/200] 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 177/200] 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 178/200] 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 179/200] 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 180/200] 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 181/200] 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 182/200] 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 183/200] 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 184/200] 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 185/200] 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 186/200] 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 187/200] 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 188/200] 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 189/200] 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 190/200] 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 191/200] 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 192/200] 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 193/200] 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 194/200] 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 195/200] =?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 196/200] 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 0faf63b92b87147f539b7e10f12331f1d6961c9d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 23:00:55 -0400 Subject: [PATCH 197/200] =?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 198/200] 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 199/200] 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 200/200] 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");