From a62188aa9b659f00d0b4e9b31db58882e9417ab6 Mon Sep 17 00:00:00 2001 From: con3code Date: Sun, 11 Oct 2026 07:47:05 +0900 Subject: [PATCH 001/111] 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/111] 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/111] 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/111] 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 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 005/111] 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 006/111] 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 007/111] 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 008/111] 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 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 009/111] 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 010/111] 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 011/111] 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 012/111] 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 013/111] 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 014/111] 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 015/111] 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 016/111] 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 017/111] 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 018/111] 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 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 019/111] =?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 020/111] 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 021/111] 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 022/111] 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 023/111] 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 024/111] 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 025/111] 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 026/111] 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 027/111] 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 028/111] 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 029/111] 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 260af3b5dfdc41476a8e9ba137db56ea0be468f4 Mon Sep 17 00:00:00 2001 From: fabian-co Date: Mon, 12 Oct 2026 17:45:45 -0500 Subject: [PATCH 030/111] macOS: open the window on Intel Metal by skipping indirect-call validation wgpu validates indirect draw and dispatch arguments with a compute shader it compiles when the device is created. Metal on an Intel Iris Pro (MacBookPro11,3, macOS 12.7.6) fails to compile it ("Compiler encountered an internal error" in MTLCompilerService), the device is lost, and the window never opens. DesignCraft draws and dispatches nothing indirectly, so leave the check out of the instance flags. WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on. VectorCraft does the same (storytold/vectorcraft#651). Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + apps/designcraft/src/main.rs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index 39fbe7100..b78267247 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ The file stops growing at 16 MiB. `--version` writes no file. |---|---| | `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). | | `WGPU_BACKEND` | The graphics backend that composites the window: `dx12`, `vulkan`, `gl` or `metal` (a comma-separated list lets wgpu choose among them). Default: DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, with OpenGL as the fallback. Setting it also turns off the start-up fallback described below. | +| `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. DesignCraft draws nothing indirectly and turns it off, since the compute shader the check needs fails to compile on some drivers (Metal on an Intel Iris Pro), which lost the device before the window opened. | The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 26ce2ee4f..d91ca40cb 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -289,6 +289,14 @@ fn main() -> eframe::Result { { create.instance_descriptor.backends = s.backends(); } + // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a + // compute shader when the device is created, one that Metal can't compile on an Intel Iris + // Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns + // the check back on. + if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + create.instance_descriptor.flags = + create.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); + } // winit has no file drag-and-drop on Wayland (only on X11), so dropping images from the file // manager showed a "no" cursor. Run through XWayland when it's there; DESIGNCRAFT_WAYLAND=1 // keeps the native Wayland backend. From 6f4fa3e56da61b4fafacdf6e01887c87711217d6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 11:14:47 +0200 Subject: [PATCH 031/111] 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 032/111] 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 033/111] 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 034/111] 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 39ec5013517d085caab5d1998eb154aa14bbb907 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 10:12:43 +0200 Subject: [PATCH 035/111] 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 036/111] 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 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 037/111] =?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 038/111] 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 039/111] =?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 e3ee342a4addab0c92e0715dee694a605987b120 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 12:12:16 +0000 Subject: [PATCH 040/111] 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 041/111] 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 042/111] 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 043/111] =?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 044/111] 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 7a30e5427e754e49e2f8a4f148db7ef96853c56f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 15:58:05 +0200 Subject: [PATCH 045/111] 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 046/111] 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 14:17:13 +0000 Subject: [PATCH 047/111] 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 048/111] 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 8817e0701364d4bf409dccfe9596df6b109dbe9b Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:19:56 +0000 Subject: [PATCH 049/111] fix(paths): path edits refuse a path on a locked layer or in a locked group (#735) --- crates/engine/src/cmd/anchors.rs | 5 +++++ crates/engine/src/cmd/object.rs | 34 ++++++++++++++++++++++++++------ crates/engine/src/cmd/paths.rs | 1 + 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index c5015c7c4..ad18b8225 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -5,6 +5,7 @@ use designcraft_doc::{Document, ItemId, Shape}; use designcraft_geom::{Affine, Anchor, AnchorKind, Point}; use serde_json::{Value, json}; +use super::object::path_editable; use super::{CommandSpec, bad, cmd, has_doc, point_param}; use crate::{Result, Session}; @@ -69,6 +70,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { .map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect()) .unwrap_or_default(); s.edit(|d, _| { + path_editable(d, id, cmd)?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let scale = inv.determinant().abs().sqrt().max(1e-9); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; @@ -135,6 +137,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?; let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0); s.edit(|d, _| { + path_editable(d, id, "path.addAnchor")?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let q = inv * at; @@ -154,6 +157,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { fn delete_anchor(s: &mut Session, p: &Value) -> Result { let (id, si, ai) = anchor_param(p, "path.deleteAnchor")?; s.edit(|d, sel| { + path_editable(d, id, "path.deleteAnchor")?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.deleteAnchor", "no such subpath"))?; if ai >= sp.anchors.len() { @@ -181,6 +185,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result { let to = point_param(p, "to"); let corner = p.get("corner").and_then(Value::as_bool); s.edit(|d, _| { + path_editable(d, id, "path.convertAnchor")?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.convertAnchor", "no such subpath"))?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc01..a03e055ae 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1489,6 +1489,20 @@ fn unlocked(d: &Document, it: &Item) -> bool { !it.locked && d.layer(it.layer).is_none_or(|l| !l.locked) } +/// Refuses a path edit on `id` when it, a group it sits in, or its layer is locked. +pub(super) fn path_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> { + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let mut items = d.spread(loc.spread).map(|sp| &sp.items[..]).unwrap_or_default(); + for &i in &loc.path { + let Some(it) = items.get(i) else { break }; + if !unlocked(d, it) { + return Err(bad(cmd, "the path is locked")); + } + items = it.children(); + } + Ok(()) +} + fn has_editable_selection(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?; @@ -2075,6 +2089,7 @@ fn path_append(s: &mut Session, p: &Value) -> Result { let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; let subpath = p.get("subpath").and_then(Value::as_u64); s.edit(|d, _| { + path_editable(d, id, "path.appendAnchor")?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; @@ -2110,12 +2125,10 @@ fn path_set_anchor(s: &mut Session, p: &Value) -> Result { let subpath = p.get("subpath").and_then(Value::as_u64); let which = p.get("anchor").cloned().unwrap_or(Value::Null); s.edit(|d, _| { + path_editable(d, id, ID)?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - if it.locked { - return Err(bad(ID, "the path is locked")); - } let sp = match subpath { Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)), None => it.path.subpaths.last_mut(), @@ -2157,12 +2170,19 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result { { return Err(bad("path.reverse", "no such subpath")); } - let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + let ids: Vec = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + let doc = &s.doc()?.doc; + let editable: Vec = ids.iter().copied().filter(|id| path_editable(doc, *id, "path.reverse").is_ok()).collect(); + if editable.is_empty() + && let Some(id) = ids.first() + { + path_editable(doc, *id, "path.reverse")?; + } s.edit(|d, _| { let mut reversed = 0; - for id in &ids { + for id in &editable { let Some(it) = d.item_mut(*id) else { continue }; - if it.locked || it.is_group() { + if it.is_group() { continue; } for (i, sp) in it.path.subpaths.iter_mut().enumerate() { @@ -2184,6 +2204,7 @@ fn path_close(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); s.edit(|d, _| { + path_editable(d, id, "path.close")?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; if only.is_some_and(|i| i >= it.path.subpaths.len()) { return Err(bad("path.close", "no such subpath")); @@ -2206,6 +2227,7 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result { let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let handle = str_param(p, "handle").map(str::to_string); s.edit(|d, _| { + path_editable(d, id, "path.moveAnchors")?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); // Spread-space delta → inner-space delta (linear part only). diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..14609d18f 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -312,6 +312,7 @@ fn split_path(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.split", "missing id"))?; let at = point_param(p, "at").ok_or_else(|| bad("path.split", "missing at"))?; s.edit(|d, sel| { + super::object::path_editable(d, id, "path.split")?; let (sr, xf) = spread_xf(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item(id).cloned().ok_or(designcraft_doc::DocError::NoItem(id))?; if !matches!(it.content, Content::Unassigned) { From f60699278264d63ce3b9df918bb297373dd24cc8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:19:57 +0000 Subject: [PATCH 050/111] fix(properties): links to other panels open them when panel docking is on (#736) --- ROADMAP.md | 1 + crates/ui-egui/src/panel_docking_tests.rs | 27 ++++++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 28 +++++++++++++++-------- crates/ui-egui/src/widgets.rs | 1 + 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..9f768db16 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** · Path edits (`path.setAnchor`, `path.moveAnchors`, `path.appendAnchor`, `path.reverse`, `path.close`, anchor add/delete/convert, Erase, Smooth and Scissors) refuse a path that is locked, sits in a locked group or on a locked layer (#735). Properties links to the Stroke, Character, Paragraph, Effects, Text Wrap and Info panels open them in a customized panel layout too (#736). 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/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index de08e3cf5..65879b1a0 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -301,6 +301,33 @@ fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() { } } +#[test] +fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { + let mut app = app(); + 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(); + 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| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + let at = h.get_by_role_and_label(egui::accesskit::Role::Link, "Stroke").rect().center(); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + assert!(h.state().ui.docking.as_ref().unwrap().contains(&"stroke".into())); + assert_eq!(h.state().ui.docking_opened.as_deref(), Some("stroke")); +} + #[test] fn changing_workspace_during_a_drag_cancels_the_stale_gesture() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..f5acd8665 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -16,6 +16,14 @@ use crate::widgets::{ }; use crate::{DesignApp, icons}; +/// A link to another panel. `window.panel` shows it in the docked layout or as a flyout; it +/// toggles the flyout, so a flyout that is already open is left as it is. +fn show_panel(app: &mut DesignApp, panel: &str) { + if app.ui.open_panel.as_deref() != Some(panel) { + let _ = app.run("window.panel", json!({"panel": panel})); + } +} + fn units(app: &DesignApp) -> Unit { app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } @@ -467,7 +475,7 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } transform_angles(app, ui, rp); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("info".into()); + show_panel(app, "info"); } } @@ -654,7 +662,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let _ = app.run("object.stroke", json!({"swatch": n})); }); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Stroke"), 56.0)).clicked() { - app.ui.open_panel = Some("stroke".into()); + show_panel(app, "stroke"); } const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 100.0]; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { @@ -695,7 +703,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let b = block(ui, FIELD_H); icons::paint(ui.painter(), sub(b, 3.0, 1.0, 19.0, 19.0), "opacity", t.icon); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Opacity"), 56.0)).clicked() { - app.ui.open_panel = Some("effects".into()); + show_panel(app, "effects"); } if let Some(v) = place(ui, sub(b, 96.0, 0.0, 51.0, FIELD_H), |ui| { NumField::number("opacity", Some((i.opacity * 100.0).round()), "%", 0).width(51.0).range(0.0, 100.0).show(ui) @@ -707,13 +715,13 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); if mresp.clicked() { - app.ui.open_panel = Some("effects".into()); + show_panel(app, "effects"); } ui.add_space(6.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; if icon_toggle(ui, "frame-dashed", false, crate::i18n::tr(&app.ui.language, "Frame Fitting")).clicked() { - app.ui.open_panel = Some("textWrap".into()); + show_panel(app, "textWrap"); } if icon_toggle(ui, "fx", i.shadow, crate::i18n::tr(&app.ui.language, "Effects: Drop Shadow")).clicked() { let _ = app.run("object.dropShadow", json!({"on": !i.shadow})); @@ -801,7 +809,7 @@ fn wrap_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } }); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("textWrap".into()); + show_panel(app, "textWrap"); } } @@ -1018,7 +1026,7 @@ fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { ); } else { if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, label), 56.0)).clicked() { - app.ui.open_panel = Some("stroke".into()); + show_panel(app, "stroke"); } const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { @@ -1125,7 +1133,7 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| language_menu(app, ui, &lang)); }); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("character".into()); + show_panel(app, "character"); } } @@ -1157,7 +1165,7 @@ fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) { } }); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("paragraph".into()); + show_panel(app, "paragraph"); } } @@ -1175,7 +1183,7 @@ fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 66.0).clicked() { - app.ui.open_panel = Some("paragraph".into()); + show_panel(app, "paragraph"); } }); }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index bcb27ddf9..af5a7b46c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -364,6 +364,7 @@ pub fn link_label(ui: &mut Ui, text: &str, width: f32) -> Response { ui.painter().galley(pos, g, t.text); let y = r.center().y + 6.5; ui.painter().line_segment([pos2(r.min.x, y), pos2(r.min.x + w, y)], Stroke::new(1.0, if resp.hovered() { t.text_strong } else { t.text_dim })); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, text)); resp.on_hover_cursor(egui::CursorIcon::PointingHand) } From 4eb786e8803f75b75d091ed982feb41c096f4279 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:22:58 +0000 Subject: [PATCH 051/111] fix(recovery): an explicit recovery folder never overwrites or deletes another run's checkpoints (#715) --- ROADMAP.md | 1 + crates/engine/src/recovery.rs | 214 +++++++++++++++++++++++++++------- docs/target-app-parity.md | 2 +- 3 files changed, 176 insertions(+), 41 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..d42bd0c1a 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** · An explicit recovery folder (`file.recovery.save` with `dir`) never overwrites or deletes another running app's checkpoints. Document uids start at 1 in every process, so two apps saving recovery to one chosen folder replaced or removed each other's `.designcraft`. A run replaces or removes only the entries it wrote or recovered there, refuses an occupied uid, and refuses a folder whose run lock another app holds. 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/recovery.rs b/crates/engine/src/recovery.rs index 33b9a2a33..ac1d8d346 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -11,6 +11,10 @@ //! An app that starts moves into its own folder what apps that have ended left in theirs, and //! leaves the folder of one still running alone. //! +//! A folder given to `file.recovery.save` that is not this run's own may hold other runs' entries +//! under the same uids. This run writes there only entries it finds free, replaces and removes +//! only those it wrote or recovered, and refuses the folder of a run whose lock is still held. +//! //! Each file is replaced atomically, but the pair is not a transaction. The document is published //! and made durable before its metadata so a crash can leave metadata missing or stale, never a //! listed metadata record without a complete primary document. Listing and opening recovery data @@ -192,10 +196,11 @@ fn lock_marker(file: &std::fs::File) -> std::io::Result> { } /// A run's folder that was removed while it runs is made again by the next save: so is its lock. +/// Returns whether `dir` is this run's folder. #[cfg(not(target_arch = "wasm32"))] -fn relock(dir: &Path, created: bool) -> Result<()> { +fn relock(dir: &Path, created: bool) -> Result { let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(()) }; + let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(false) }; validate_recovery_dir(&run.root)?; #[cfg(unix)] let directory = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; @@ -219,7 +224,7 @@ fn relock(dir: &Path, created: bool) -> Result<()> { return Err(EngineError::Other(format!("{}: this run no longer owns its recovery folder", dir.display()))); } match std::fs::symlink_metadata(dir.join(LOCK)) { - Ok(_) => return validate_lock(dir, &run.lock), + Ok(_) => return validate_lock(dir, &run.lock).map(|()| true), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), } @@ -242,7 +247,93 @@ fn relock(dir: &Path, created: bool) -> Result<()> { { run.directory = replacement_directory; } - Ok(()) + Ok(true) +} + +/// Entries this process wrote or recovered in folders that are not its run's own: (canonical +/// folder, uid). Document uids start at 1 in every process, so a shared folder's entry under +/// one of this run's uids may be another run's. +static WRITTEN: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeSet::new()); + +/// What this run may do in a recovery folder. +struct Access { + /// This run's own folder (`run_dir`): every entry in it is this run's. + own: bool, + key: PathBuf, +} + +impl Access { + fn owns(&self, uid: u64) -> bool { + self.own || WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&(self.key.clone(), uid)) + } + + fn record(&self, uid: u64) { + if !self.own { + WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert((self.key.clone(), uid)); + } + } + + fn forget(&self, uid: u64) { + WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&(self.key.clone(), uid)); + } + + /// Refuse an entry of another run before anything is written: this run never replaces it. + fn check_free(&self, dir: &Path, uid: u64) -> Result<()> { + if self.owns(uid) { + return Ok(()); + } + let (doc, meta) = files(dir, uid); + for path in [doc, meta] { + match std::fs::symlink_metadata(&path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + _ => { + return Err(EngineError::Other(format!( + "{}: recovery data this run did not write is there; recover it or choose another recovery folder", + path.display() + ))); + } + } + } + Ok(()) + } +} + +/// Check what this run may do in `dir`. A folder that is not this run's own may be the folder +/// of another running app, whose lock that app holds: this run leaves it alone. +fn access(dir: &Path, created: bool) -> Result { + #[cfg(not(target_arch = "wasm32"))] + let own = relock(dir, created)?; + #[cfg(not(target_arch = "wasm32"))] + if !own { + check_unheld(dir)?; + } + #[cfg(target_arch = "wasm32")] + let own = { + let _ = created; + false + }; + Ok(Access { own, key: directory_key(dir)? }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn check_unheld(dir: &Path) -> Result<()> { + let path = dir.join(LOCK); + let in_use = + || EngineError::Other(format!("{}: another running DesignCraft keeps its recovery data here; choose another recovery folder", dir.display())); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if is_link_like(&metadata) || !metadata.file_type().is_file() => return Err(in_use()), + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(EngineError::Other(format!("{}: {e}", path.display()))), + } + // Opened for writing: over NFS an exclusive lock on a file opened for reading is refused. + let lock = std::fs::OpenOptions::new().read(true).write(true).open(&path).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + match lock.try_lock() { + // Free: the run that held it has ended. Dropping the handle releases it again. + Ok(()) => Ok(()), + Err(std::fs::TryLockError::WouldBlock) => Err(in_use()), + Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", path.display()))), + } } /// Move into `own`, this run's folder, what is left to recover beside it: the entries of runs @@ -329,15 +420,21 @@ fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { (dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json"))) } -/// Remove a document's recovery entry (it was saved or closed). +/// Remove a document's recovery entry (it was saved or closed). In a folder that is not this +/// run's own, only an entry this run wrote or recovered is removed. pub fn discard(dir: &Path, uid: u64) { #[cfg(not(target_arch = "wasm32"))] - if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() { + if validate_recovery_dir(dir).is_err() { + return; + } + let Ok(access) = access(dir, false) else { return }; + if !access.owns(uid) { return; } let (d, m) = files(dir, uid); let _ = std::fs::remove_file(d); let _ = std::fs::remove_file(m); + access.forget(uid); } /// Consume this document's checkpoint provenance after Save/Close/Revert. A default-directory @@ -385,29 +482,29 @@ fn protect_retained_sources(s: &Session, dir: &Path) -> Result<()> { Ok(()) } -fn prepare_save(s: &Session, dir: &Path) -> Result<()> { +fn prepare_save(s: &Session, dir: &Path) -> Result { // Check before any write or deletion: a failed republish can retain a source whose uid // coincides with an unrelated live document in a manually configured recovery directory. protect_retained_sources(s, dir)?; let created = secure_recovery_dir(dir)?; - #[cfg(not(target_arch = "wasm32"))] - relock(dir, created)?; - #[cfg(target_arch = "wasm32")] - let _ = created; - Ok(()) + let access = access(dir, created)?; + for document in s.docs.iter().filter(|d| d.is_dirty()) { + access.check_free(dir, document.uid)?; + } + Ok(access) } /// Write recovery entries for dirty documents and drop clean checkpoints. Returns how many /// were written. Command callers use the tracked variant without configuring automatic recovery. pub fn save(s: &Session, dir: &Path) -> Result { - prepare_save(s, dir)?; + let access = prepare_save(s, dir)?; let mut n = 0; for d in &s.docs { if !d.is_dirty() { discard(dir, d.uid); continue; } - write_entry(dir, d)?; + write_entry(&access, dir, d)?; n += 1; } Ok(n) @@ -424,7 +521,7 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result { )); } } - prepare_save(s, dir)?; + let access = prepare_save(s, dir)?; let mut n = 0; for index in 0..s.docs.len() { if s.docs.get(index).is_some_and(|document| !document.is_dirty()) { @@ -435,25 +532,30 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result { let (doc, meta) = files(dir, document.uid); let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref())) .map_err(|e| EngineError::Other(e.to_string()))?; - write_recovery_file(&doc, &bytes)?; + // An entry this run did not write is never replaced, even one made since it checked. + let replace = access.owns(document.uid); + publish_recovery_file(&doc, &bytes, replace)?; + access.record(document.uid); // The primary is useful even if optional metadata fails next. Track it immediately. if !has_checkpoint(document, dir) { document.recovery_entries.push((dir.to_path_buf(), document.uid)); } let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()}); - write_recovery_file(&meta, metadata.to_string().as_bytes())?; + publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)?; n += 1; } Ok(n) } /// Publish a document's recovery entry before its optional metadata. -fn write_entry(dir: &Path, d: &DocState) -> Result<()> { +fn write_entry(access: &Access, dir: &Path, d: &DocState) -> Result<()> { let (doc, meta) = files(dir, d.uid); let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?; - write_recovery_file(&doc, &bytes)?; + let replace = access.owns(d.uid); + publish_recovery_file(&doc, &bytes, replace)?; + access.record(d.uid); let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()}); - write_recovery_file(&meta, metadata.to_string().as_bytes()) + publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace) } #[cfg(not(target_arch = "wasm32"))] @@ -499,11 +601,6 @@ fn secure_recovery_dir(_dir: &Path) -> Result { Err(EngineError::Other("recovery files are unavailable on the web".into())) } -#[cfg(not(target_arch = "wasm32"))] -fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> { - publish_recovery_file(path, bytes, true) -} - #[cfg(not(target_arch = "wasm32"))] fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> { let Some(dir) = path.parent() else { @@ -581,11 +678,6 @@ fn sync_recovery_dir(dir: &Path) -> Result<()> { Ok(()) } -#[cfg(target_arch = "wasm32")] -fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> { - Err(EngineError::Other("recovery files are unavailable on the web".into())) -} - #[cfg(target_arch = "wasm32")] fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<()> { Err(EngineError::Other("recovery files are unavailable on the web".into())) @@ -661,18 +753,13 @@ pub fn open(s: &mut Session, dir: &Path) -> Result> { fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result> { #[cfg(not(target_arch = "wasm32"))] - { - validate_recovery_dir(dir)?; - relock(dir, false)?; - } + validate_recovery_dir(dir)?; + let source = access(dir, false)?; let destination = s.recovery_dir.clone().unwrap_or_else(|| dir.to_path_buf()); let created = secure_recovery_dir(&destination)?; - #[cfg(not(target_arch = "wasm32"))] - relock(&destination, created)?; - #[cfg(target_arch = "wasm32")] - let _ = created; - let source_key = directory_key(dir)?; - let destination_key = directory_key(&destination)?; + let target = access(&destination, created)?; + let source_key = source.key.clone(); + let destination_key = target.key.clone(); let mut opened = Vec::new(); // For this run's own folder, first bring in what runs that ended left in theirs. #[cfg(not(target_arch = "wasm32"))] @@ -705,6 +792,8 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat let mut st = DocState::new(d, path); reserve_uid(&mut st, &mut occupied)?; st.recovery_entries.push((dir.to_path_buf(), uid)); + // Recovered, the entry is this run's: Save and Close of its document may remove it. + source.record(uid); st.fonts = fonts; // Unsaved: the copy on disk (if any) is older than what was recovered. st.saved_doc = std::sync::Arc::new((*st.doc).clone()); @@ -721,6 +810,7 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat .and_then(|bytes| publish(&document, &bytes)); match published { Ok(()) => { + target.record(state.uid); state.recovery_entries.push((destination.clone(), state.uid)); let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()}); match publish(&metadata, meta.to_string().as_bytes()) { @@ -1477,6 +1567,50 @@ mod tests { assert!(!left.join(LOCK).exists()); assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock"); } + + /// Another run's entry under uid 7 in a chosen folder, with a document of this run numbered + /// 7 too: document uids start at 1 in every process. + fn chosen_folder_with_another_runs_entry() -> (tempfile::TempDir, PathBuf, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let (doc, meta) = files(root.path(), 7); + std::fs::write(&doc, b"another run's document").unwrap(); + std::fs::write(&meta, b"{\"title\": \"theirs\"}").unwrap(); + (root, doc, meta) + } + + #[test] + fn a_clean_document_leaves_another_runs_entry_in_a_chosen_folder() { + let (root, doc, meta) = chosen_folder_with_another_runs_entry(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.docs[0].uid = 7; + assert!(!s.docs[0].is_dirty()); + s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); + assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); + } + + #[test] + fn a_dirty_document_does_not_overwrite_another_runs_entry_in_a_chosen_folder() { + let (root, doc, meta) = chosen_folder_with_another_runs_entry(); + let mut s = unsaved("this run's work"); + s.docs[0].uid = 7; + assert!(s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); + assert!(save(&s, root.path()).is_err()); + assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); + assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); + } + + /// A chosen folder that is a running app's run folder is that app's. + #[test] + fn a_folder_another_running_app_holds_is_refused() { + let root = tempfile::tempdir().unwrap(); + let held = new_lock(root.path()).unwrap(); + held.try_lock().unwrap(); + assert!(save(&dirty_session(), root.path()).is_err()); + assert!(list(root.path()).is_empty()); + } } #[cfg(test)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..b76a821c4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -290,7 +290,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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). - Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. -- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. A folder given to `file.recovery.save` keeps other runs' entries: a run replaces or removes only the entries it wrote or recovered there, and refuses a folder whose run lock another app holds. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Window close, application/menu Quit and macOS Dock Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate. - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). From 368958ee2f8509d031767ea05d2ebeccdcb056b2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:51:52 +0000 Subject: [PATCH 052/111] 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 053/111] 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 054/111] 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 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 055/111] #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 056/111] 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 057/111] 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 058/111] 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 059/111] 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 060/111] 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 061/111] 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 062/111] 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 063/111] 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 064/111] 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 7e344e85f17737c02f7f4e5e57180ebca80d6df8 Mon Sep 17 00:00:00 2001 From: Govinda Roy Date: Wed, 14 Oct 2026 00:16:42 +0600 Subject: [PATCH 065/111] 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 066/111] 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 067/111] 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 efd2310c83c6936307153415e494c8704670cf09 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 13 Oct 2026 13:20:47 -0600 Subject: [PATCH 068/111] 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 be98657a221dac9af5c540520eb78e4f9fdfe51f Mon Sep 17 00:00:00 2001 From: Andres Torresi Date: Wed, 14 Oct 2026 08:42:28 +0100 Subject: [PATCH 069/111] 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 070/111] 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 a2c0cb79cec994d1a18895d2eddcb4aba15a66d9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 10:39:03 +0200 Subject: [PATCH 071/111] 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 188c4f7007f0bb05dfc198d7dae478e4149f796e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 12:03:58 +0200 Subject: [PATCH 072/111] 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 073/111] =?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 074/111] 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 075/111] 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 076/111] Linux: build an Arch package (.pkg.tar.zst) package.sh now also builds designcraft--linux-.pkg.tar.zst with nfpm's archlinux packager, from the same nfpm.yaml. It is named by the bundle id (ai.storyteller.designcraft), provides designcraft and conflicts with designcraft, designcraft-bin and designcraft-git, so an AUR helper doesn't take it over and ArtCraft Launcher can tell it from an AUR build. The README lists it with the other Linux downloads. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + packaging/linux/nfpm.yaml | 20 +++++++++++++++++++- packaging/linux/package.sh | 21 +++++++++++++++------ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0ec6540ba..7268b3d3d 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,7 @@ Installers and executables are code-signed. | Flatpak | `designcraft--linux-x86_64.flatpak` | `designcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | | Debian/Ubuntu | `designcraft--linux-x86_64.deb` | `designcraft--linux-aarch64.deb` | | | Fedora/RHEL/openSUSE | `designcraft--linux-x86_64.rpm` | `designcraft--linux-aarch64.rpm` | | +| Arch Linux/Manjaro | `designcraft--linux-x86_64.pkg.tar.zst` | `designcraft--linux-aarch64.pkg.tar.zst` | `sudo pacman -U `; installs as `ai.storyteller.designcraft`, replacing an AUR package | | Tarball | `designcraft--linux-x86_64.tar.gz` | `designcraft--linux-aarch64.tar.gz` | Unpack anywhere | RISC-V (riscv64): `designcraft--linux-riscv64.tar.gz` only, cross-compiled; needs glibc 2.39+ (Ubuntu 24.04 or newer). diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index e2ce72b10..350db2e0f 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -1,4 +1,4 @@ -# .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages +# .deb/.rpm/Arch packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages # the file tree, substitutes its path for ${STAGE} (nfpm doesn't expand env vars in `src`) and # exports VERSION and NFPM_ARCH. # @@ -53,6 +53,24 @@ overrides: - libXrandr - mesa-vulkan-drivers - xdg-desktop-portal + # Named ai.storyteller.designcraft, not designcraft: the AUR already has designcraft, designcraft-bin and + # designcraft-git, and an AUR helper treats any installed package with an AUR name as its own and + # rebuilds it on the next update. ArtCraft Launcher also uses the bundle id to tell this package + # (which it updates with `pacman -U`) from an AUR build (which it leaves to the AUR helper). + # nfpm has no optdepends for Arch, so the recommends above have no equivalent here. + archlinux: + depends: + - glibc + - gcc-libs + - hicolor-icon-theme + - libxkbcommon + - vulkan-icd-loader + provides: + - designcraft + conflicts: + - designcraft + - designcraft-bin + - designcraft-git contents: - src: ${STAGE}/usr/bin/designcraft diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index fd7fc4ef8..d6391a9cd 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -5,11 +5,12 @@ # $DIST/designcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) # $DIST/designcraft--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ... # $DIST/designcraft--linux-.rpm Fedora, openSUSE, RHEL, ... +# $DIST/designcraft--linux-.pkg.tar.zst Arch, Manjaro, EndeavourOS, ... # $DIST/designcraft--linux-.tar.gz plain FHS-style tree (bin/, share/) # -# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] +# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm arch tar"] # -# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage +# Needs: cargo; nfpm for deb/rpm/arch (https://nfpm.goreleaser.com); appimagetool for the AppImage # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. @@ -20,12 +21,12 @@ HERE="$ROOT/packaging/linux" APP_ID=ai.storyteller.designcraft SKIP_BUILD=0 -FORMATS="appimage deb rpm tar" +FORMATS="appimage deb rpm arch tar" while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -h | --help) sed -n '2,16p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done @@ -85,8 +86,8 @@ if has tar; then echo "wrote $DIST/$BASENAME.tar.gz" fi -# ---- .deb / .rpm -------------------------------------------------------------------------------- -if has deb || has rpm; then +# ---- .deb / .rpm / .pkg.tar.zst ----------------------------------------------------------------- +if has deb || has rpm || has arch; then command -v nfpm >/dev/null || { echo "error: nfpm not found (https://nfpm.goreleaser.com/install/)" >&2; exit 1; } export VERSION export NFPM_ARCH="$DEB_ARCH" @@ -95,6 +96,14 @@ if has deb || has rpm; then for fmt in deb rpm; do if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi done + # The Arch package is named by the bundle id (see `archlinux` in nfpm.yaml), which nfpm can't + # override per format, and `archlinux.packager` isn't env-expanded, so both are written here. + if has arch; then + sed "s|^name: designcraft\$|name: $APP_ID|" "$WORK/nfpm.yaml" >"$WORK/nfpm-arch.yaml" + grep -qx "name: $APP_ID" "$WORK/nfpm-arch.yaml" || { echo "error: couldn't rename the Arch package" >&2; exit 1; } + printf 'archlinux:\n packager: "%s"\n' "$DESIGNCRAFT_MAINTAINER" >>"$WORK/nfpm-arch.yaml" + (cd "$ROOT" && nfpm package -f "$WORK/nfpm-arch.yaml" -p archlinux -t "$DIST/$BASENAME.pkg.tar.zst") + fi fi # ---- AppImage ----------------------------------------------------------------------------------- From c7b3ee4f45983d2f2b45d5a0133fe77117e34c85 Mon Sep 17 00:00:00 2001 From: Govinda Roy Date: Wed, 14 Oct 2026 19:39:37 +0600 Subject: [PATCH 077/111] 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 078/111] 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 079/111] 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 080/111] 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 081/111] 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 082/111] 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 cbba1fe7e1cc1e148e0e862090938f704b86e0ab Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 18:15:14 +0200 Subject: [PATCH 083/111] 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 084/111] 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 085/111] 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 19bacaadd7f8e9c1fd532466a405da2a08322b8e Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:41:51 +0400 Subject: [PATCH 086/111] 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 087/111] 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 088/111] 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 089/111] 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 090/111] 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 091/111] 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 149eaf3095bf43040535f44fcbd577152276775a Mon Sep 17 00:00:00 2001 From: Govinda Roy Date: Wed, 14 Oct 2026 19:52:22 +0600 Subject: [PATCH 092/111] 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 d3309401f5a5d412023fc245c31caa802b6f74cb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 20:30:21 +0200 Subject: [PATCH 093/111] 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 094/111] 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 095/111] 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 096/111] 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 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 097/111] 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 098/111] 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 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 099/111] 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 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 100/111] 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 101/111] 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 102/111] 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 103/111] 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 104/111] 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 105/111] 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 099fbd23ee2179dd460a3e9e23652fb063baa346 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 00:54:34 +0200 Subject: [PATCH 106/111] 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 89f6adb6fbc3d9d5a19d0be177c19daede7794d0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 15 Oct 2026 01:25:04 +0200 Subject: [PATCH 107/111] 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 2dcef6c951cea7a8638ef930cad8edf6827ee39e Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Thu, 15 Oct 2026 03:38:25 +0400 Subject: [PATCH 108/111] 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 109/111] 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 110/111] 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 111/111] =?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:?}"); }