From 86356d15be24374e56f29b626f0624a19b542834 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 00:34:20 +0200 Subject: [PATCH 01/71] Layout: Go Back and Go Forward through the pages viewed, with InDesign's shortcuts --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 66 ++++++++++++- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/lib.rs | 11 +++ crates/ui-egui/src/menus.rs | 151 +++++++++++++++++++++++++++++ crates/ui-egui/src/new_document.rs | 4 +- crates/ui-egui/src/page_history.rs | 148 ++++++++++++++++++++++++++++ docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- 17 files changed, 399 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/page_history.rs diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..42368dc25 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318f82388..dddcfedbe 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -238,8 +238,14 @@ pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> { Some((pi, qi)) } -/// Scroll so absolute page `abs` is centred (keeps zoom). +/// Scroll so absolute page `abs` is centred (keeps zoom). A jump Go Back can return from. pub fn go_to_page(app: &mut DesignApp, abs: usize) { + note_jump(app, false); + centre_page(app, abs); + note_jump(app, true); +} + +fn centre_page(app: &mut DesignApp, abs: usize) { let Some(st) = app.session.active() else { return }; let layout = CanvasLayout::new(&st.doc, false); let Some(pr) = layout.page_rect(&st.doc, abs) else { return }; @@ -249,6 +255,58 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) { } } +/// The document's pages by id, in page order. +fn page_ids(doc: &Document) -> Vec { + doc.spreads.iter().flat_map(|s| s.pages.iter().map(|p| p.id)).collect() +} + +/// The page in view and the view centre's offset from that page's centre. Parent pages have none, +/// nor has a view still waiting to be fitted (leaving parent pages for a document page). +fn page_in_view(app: &DesignApp) -> Option<(designcraft_doc::ids::PageId, DVec2)> { + let st = app.session.active().filter(|st| !st.editing_parents)?; + let (v, r) = (app.view().filter(|v| v.fitted)?, app.canvas_rect?); + let abs = current_page(app)?; + let id = st.doc.page(abs)?.id; + let pr = CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)?; + let c = Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom); + Some((id, c - pr.center())) +} + +/// Layout ▸ Go Back / Go Forward record jumps (Go to Page, the page and spread commands, the Pages +/// panel), not scrolling: where the view is when a jump starts, and where the jump took it. +fn note_jump(app: &mut DesignApp, to: bool) { + let Some((id, offset)) = page_in_view(app) else { return }; + if let Some(h) = app.page_history_mut() { + if to { h.visit(id, offset) } else { h.leave(id, offset) } + } +} + +/// Whether Layout ▸ Go Back (`step` < 0) or Go Forward has a page to go to from the view as it is. +pub fn can_go_through_history(app: &DesignApp, step: isize) -> bool { + let Some(st) = app.session.active().filter(|st| !st.editing_parents) else { return false }; + let ids = page_ids(&st.doc); + let Some(mut h) = app.page_history().cloned() else { return false }; + if let Some((id, offset)) = page_in_view(app) { + h.leave(id, offset); + } + h.can_go(step, |id| ids.contains(&id)) +} + +/// Layout ▸ Go Back (`step` < 0) or Go Forward: show the page viewed before or after this one, where +/// the view was on it (zoom kept). +pub fn go_through_history(app: &mut DesignApp, step: isize) { + note_jump(app, false); + let Some(ids) = app.session.active().filter(|st| !st.editing_parents).map(|st| page_ids(&st.doc)) else { return }; + let Some((id, offset)) = app.page_history_mut().and_then(|h| h.go(step, |id| ids.contains(&id))) else { return }; + let Some(st) = app.session.active() else { return }; + let Some(pr) = ids.iter().position(|p| *p == id).and_then(|abs| CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)) else { return }; + let Some(rect) = app.canvas_rect else { return }; + let c = pr.center() + offset; + if let Some(v) = app.view_mut() { + v.origin = Point::new(c.x - rect.width() as f64 / 2.0 / v.zoom, c.y - rect.height() as f64 / 2.0 / v.zoom); + } +} + /// Presentation: the spread in view, centred and as large as `rect` allows. fn present_fit(app: &mut DesignApp, rect: Rect) { let Some(st) = app.session.active() else { return }; @@ -268,6 +326,12 @@ fn present_fit(app: &mut DesignApp, rect: Rect) { /// Centre the spread `delta` spreads after (or before) the one in view (keeps zoom); stops at the /// first and last spread. Parent spreads while they are being edited. pub fn step_spread(app: &mut DesignApp, delta: isize) { + note_jump(app, false); + centre_spread(app, delta); + note_jump(app, true); +} + +fn centre_spread(app: &mut DesignApp, delta: isize) { let Some(st) = app.session.active() else { return }; let layout = CanvasLayout::new(&st.doc, st.editing_parents); let Some(cur) = current_slot(app, &layout) else { return }; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6057abc97..74d15a2d6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -125,6 +125,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Previous Page", ["Vorherige Seite", "Page précédente", "Página anterior", "前のページ", "上一页"]), ("Next Page", ["Nächste Seite", "Page suivante", "Página siguiente", "次のページ", "下一页"]), ("Last Page", ["Letzte Seite", "Dernière page", "Última página", "最後のページ", "最后一页"]), + ("Go Back", ["Zurück gehen", "Revenir", "Retroceder", "前に戻る", "后退"]), + ("Go Forward", ["Vorwärts gehen", "Avancer", "Avanzar", "次に進む", "前进"]), ( "Close Tab Group", ["Registerkartengruppe schließen", "Fermer le groupe d'onglets", "Cerrar grupo de fichas", "タブグループを閉じる", "关闭选项卡组"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..3aa1871a7 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -170,6 +170,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "الصفحة السابقة"), ("First Page", "الصفحة الأولى"), ("Last Page", "الصفحة الأخيرة"), + ("Go Back", "رجوع"), + ("Go Forward", "تقدم"), ("Font", "خط"), ("Size", "الحجم"), ("Character", "حرف"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..f8be53d2d 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1443,6 +1443,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Předchozí stránka"), ("First Page", "První stránka"), ("Last Page", "Poslední stránka"), + ("Go Back", "Přejít zpět"), + ("Go Forward", "Přejít vpřed"), ("Object Styles", "Styly objektů"), ("Row", "Řádek"), ("Column", "Sloupec"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..2441530a2 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1617,6 +1617,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Pagina precedente"), ("First Page", "Prima pagina"), ("Last Page", "Ultima pagina"), + ("Go Back", "Torna indietro"), + ("Go Forward", "Vai avanti"), ("Object Styles", "Stili oggetto"), ("Row", "Riga"), ("Column", "Colonna"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..16879e406 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1549,6 +1549,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "წინა გვერდი"), ("First Page", "პირველი გვერდი"), ("Last Page", "ბოლო გვერდი"), + ("Go Back", "უკან დაბრუნება"), + ("Go Forward", "წინ გადასვლა"), ("Object Styles", "ობიექტის სტილები"), ("Row", "სტრიქონი"), ("Rulers", "სახაზავები"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..731e6ceeb 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1480,6 +1480,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Poprzednia strona"), ("First Page", "Pierwsza strona"), ("Last Page", "Ostatnia strona"), + ("Go Back", "Przejdź wstecz"), + ("Go Forward", "Przejdź dalej"), ("Object Styles", "Style obiektów"), ("Row", "Wiersz"), ("Rulers", "Linijki"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..9c83f1d0b 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -176,6 +176,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Página anterior"), ("First Page", "Primeira página"), ("Last Page", "Última página"), + ("Go Back", "Voltar"), + ("Go Forward", "Avançar"), ("Font", "Fonte"), ("Size", "Tamanho"), ("Character", "Caractere"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..971579d9f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1431,6 +1431,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Предыдущая страница"), ("First Page", "Первая страница"), ("Last Page", "Последняя страница"), + ("Go Back", "Назад"), + ("Go Forward", "Вперёд"), ("Object Styles", "Стили объектов"), ("Row", "Строка"), ("Column", "Столбец"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..deefd5759 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1614,6 +1614,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Попередня сторінка"), ("First Page", "Перша сторінка"), ("Last Page", "Остання сторінка"), + ("Go Back", "Назад"), + ("Go Forward", "Вперед"), ("Object Styles", "Стилі об’єктів"), ("Row", "Рядок"), ("Column", "Стовпець"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 535c87629..0b1add9ac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod menus; mod modal_drag; mod new_document; mod object_control; +mod page_history; mod panel_docking; pub mod panels; mod prefs; @@ -559,6 +560,8 @@ pub struct DesignApp { pub ui: UiState, pub services: Services, pub views: HashMap, + /// Layout ▸ Go Back / Go Forward, by view (like `views`). + pub(crate) page_history: HashMap, /// The canvas cache and rect of the pane in `pane` (the other pane's are in `other_pane`). pub canvas: CanvasCache, pub canvas_rect: Option, @@ -662,6 +665,7 @@ impl DesignApp { ui: UiState::default(), services, views: HashMap::new(), + page_history: HashMap::new(), canvas: CanvasCache::new(), canvas_rect: None, split: false, @@ -726,6 +730,13 @@ impl DesignApp { let k = self.view_key()?; Some(self.views.entry(k).or_default()) } + pub(crate) fn page_history(&self) -> Option<&page_history::PageHistory> { + self.page_history.get(&self.view_key()?) + } + pub(crate) fn page_history_mut(&mut self) -> Option<&mut page_history::PageHistory> { + let k = self.view_key()?; + Some(self.page_history.entry(k).or_default()) + } /// Make `pane` current: its canvas cache and rect move in. pub fn switch_pane(&mut self, pane: u8) { if pane == self.pane { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd7..2a3ffebc2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -237,6 +237,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.nextSpread", "Next Spread", Some("Alt+PageDown"), "{} — centre the spread after the one in view"), ("view.previousSpread", "Previous Spread", Some("Alt+PageUp"), "{} — centre the spread before the one in view"), ("view.goToPage", "Go to Page…", Some("Cmd+J"), "{page: number (position) | \"name\" (section page name, or \"+n\" for a position)}"), + ("view.goBack", "Go Back", Some("Cmd+PageUp"), "{} — show the page viewed before this one in the window"), + ("view.goForward", "Go Forward", Some("Cmd+PageDown"), "{} — show the page viewed after this one (after Go Back)"), ( "window.panel", "Show Panel", @@ -427,6 +429,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.nextSpread", "ui:view.previousSpread", "ui:view.goToPage", + "ui:view.goBack", + "ui:view.goForward", "-", "cmd:layout.section", "cmd:toc.generate", @@ -1627,6 +1631,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + crate::canvas::go_through_history(app, if id == "view.goBack" { -1 } else { 1 }); + Ok(Value::Null) + } "view.nextSpread" | "view.previousSpread" => { crate::canvas::step_spread(app, if id == "view.nextSpread" { 1 } else { -1 }); Ok(Value::Null) @@ -2464,6 +2472,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { return false; } + if id == "view.goBack" || id == "view.goForward" { + return crate::canvas::can_go_through_history(app, if id == "view.goBack" { -1 } else { 1 }); + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help."); } @@ -4195,6 +4206,146 @@ mod tests { assert_eq!(v.zoom, zoom); } + #[test] + fn go_back_and_go_forward_retrace_the_pages_viewed() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap(); + let can = |app: &crate::DesignApp| (menu_enabled(app, "view.goBack"), menu_enabled(app, "view.goForward")); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // In the Layout menu after Go to Page. + let layout = MENUS.iter().find(|m| m.0 == "Layout").unwrap().1; + let at = |id: &str| layout.iter().position(|e| *e == format!("ui:{id}")).unwrap(); + assert_eq!(at("view.goBack"), at("view.goToPage") + 1); + assert_eq!(at("view.goForward"), at("view.goBack") + 1); + assert_eq!(can(&app), (false, false), "one page viewed: nowhere to go"); + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + let cmd_shift = egui::Modifiers { shift: true, ..cmd }; + let shift = egui::Modifiers::SHIFT; + // Pages viewed: 1, 5, 7. + app.run("view.goToPage", json!({"page": 5})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, key(egui::Key::PageDown, cmd_shift)); + assert_eq!(page(&app), 6); + frame(&mut app, vec![]); + assert_eq!(can(&app), (true, false)); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 4, "Go Back"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 0, "Go Back again"); + frame(&mut app, vec![]); + assert_eq!(can(&app), (false, true), "back at the first page viewed"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(page(&app), 4, "Go Forward"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(page(&app), 6, "Go Forward again"); + frame(&mut app, vec![]); + assert_eq!(can(&app), (true, false)); + // After going back, viewing another page drops the pages ahead. + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 4); + frame(&mut app, key(egui::Key::PageDown, shift)); + assert_eq!(page(&app), 5, "Next Page"); + frame(&mut app, vec![]); + assert_eq!(can(&app), (true, false), "nothing ahead after a new page"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(page(&app), 5, "Go Forward does nothing"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 4, "Go Back returns to the page before Next Page"); + } + + #[test] + fn go_back_returns_to_the_view_a_jump_left_and_scrolling_adds_no_entry() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let spread = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap() + }; + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + let alt = egui::Modifiers::ALT; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // Pages 1 | 2-3 | 4-5 | 6-7. Spread 2-3 fitted: the view centre is on the fold. + frame(&mut app, key(egui::Key::PageDown, alt)); + app.run("view.fitSpread", json!({})).unwrap(); + frame(&mut app, vec![]); + let before = *app.view().unwrap(); + frame(&mut app, key(egui::Key::PageDown, alt)); + assert_eq!(spread(&app), 2, "Next Spread"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + let v = *app.view().unwrap(); + assert!((v.origin - before.origin).hypot() < 1e-6, "Go Back shows the spread as it was: {:?} vs {:?}", v.origin, before.origin); + assert_eq!(v.zoom, before.zoom); + // Zoomed in after the jump: Go Back keeps the magnification in use, as InDesign's does, and + // centres the same place. + let centre = |app: &crate::DesignApp| { + let (v, r) = (app.view().unwrap(), app.canvas_rect.unwrap()); + designcraft_geom::Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom) + }; + let place = centre(&app); + frame(&mut app, key(egui::Key::PageDown, alt)); + app.run("view.zoomIn", json!({})).unwrap(); + let zoomed = app.view().unwrap().zoom; + assert!(zoomed > before.zoom); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(app.view().unwrap().zoom, zoomed, "the zoom in use is kept"); + assert!((centre(&app) - place).hypot() < 1e-6, "the same place: {:?} vs {place:?}", centre(&app)); + // Scrolling down two spreads is not a jump: Go Forward keeps its page, and Go Back skips + // from where the scroll ended straight to the page before the first jump. + let slots = designcraft_tools::layout::CanvasLayout::new(&app.session.active().unwrap().doc, false).slots; + app.view_mut().unwrap().origin.y += slots[3].bounds.center().y - slots[1].bounds.center().y; + frame(&mut app, vec![]); + assert_eq!(spread(&app), 3); + assert!(menu_enabled(&app, "view.goForward"), "scrolling kept the page ahead"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(spread(&app), 0, "Go Back"); + assert!(!menu_enabled(&app, "view.goBack"), "the scroll added no entry"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(spread(&app), 3, "Go Forward returns to where the scroll ended"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(spread(&app), 2); + } + #[test] fn presentation_shows_one_spread_and_goes_through_them_with_keys_and_clicks() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index ee59de800..4fd5cc4e8 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -278,11 +278,13 @@ pub(crate) fn end_stale_preview(app: &mut DesignApp) { } } -/// Drop the views (one per pane) of a preview document that has gone. +/// Drop the views and page histories (one per pane) of a preview document that has gone. fn forget_view(app: &mut DesignApp, uid: Option) { if let Some(uid) = uid.filter(|uid| app.session.documents().iter().all(|d| d.uid != *uid)) { app.views.remove(&uid); app.views.remove(&(uid ^ (1 << 63))); + app.page_history.remove(&uid); + app.page_history.remove(&(uid ^ (1 << 63))); } } diff --git a/crates/ui-egui/src/page_history.rs b/crates/ui-egui/src/page_history.rs new file mode 100644 index 000000000..177bfcf9c --- /dev/null +++ b/crates/ui-egui/src/page_history.rs @@ -0,0 +1,148 @@ +//! Layout ▸ Go Back / Go Forward: the pages a window has jumped between, in the order it showed them. +//! InDesign keeps this order for the session, like a web browser's Back and Forward. + +use designcraft_doc::ids::PageId; +use designcraft_geom::Vec2; + +/// Entries kept: the oldest go first. +const LIMIT: usize = 256; + +#[derive(Clone, Debug, Default)] +pub struct PageHistory { + /// Each page with where the view centre was on it, as an offset from the page's centre. + pages: Vec<(PageId, Vec2)>, + /// The entry in view. + at: usize, +} + +impl PageHistory { + /// A jump or Go Back starts from `page`, the view centre `offset` from the page's centre: the + /// entry in view takes that place, wherever scrolling has moved the view since it was recorded, + /// as a browser keeps a page's scroll position. + pub fn leave(&mut self, page: PageId, offset: Vec2) { + match self.pages.get_mut(self.at) { + Some(e) => *e = (page, offset), + None => self.visit(page, offset), + } + } + + /// A jump has brought the view to `page`, its centre `offset` from the page's centre. Another page + /// than the entry in view drops the entries ahead of it, as a browser does when a link is followed + /// after going back. + pub fn visit(&mut self, page: PageId, offset: Vec2) { + if let Some(e) = self.pages.get_mut(self.at) + && e.0 == page + { + e.1 = offset; + return; + } + self.pages.truncate(self.at.saturating_add(1)); + self.pages.push((page, offset)); + if self.pages.len() > LIMIT { + self.pages.remove(0); + } + self.at = self.pages.len().saturating_sub(1); + } + + /// The entry Go Back (`step` < 0) or Go Forward goes to: the nearest one that way whose page is + /// still in the document and isn't the page in view. + fn target(&self, step: isize, exists: impl Fn(PageId) -> bool) -> Option { + let current = self.pages.get(self.at).map(|e| e.0); + let usable = |i: &usize| self.pages.get(*i).is_some_and(|&(p, _)| Some(p) != current && exists(p)); + if step < 0 { (0..self.at).rev().find(usable) } else { (self.at.saturating_add(1)..self.pages.len()).find(usable) } + } + + /// Whether Go Back (`step` < 0) or Go Forward has a page to go to. + pub fn can_go(&self, step: isize, exists: impl Fn(PageId) -> bool) -> bool { + self.target(step, exists).is_some() + } + + /// Go Back (`step` < 0) or Go Forward: the page to show and where the view was on it. It becomes + /// the entry in view. + pub fn go(&mut self, step: isize, exists: impl Fn(PageId) -> bool) -> Option<(PageId, Vec2)> { + let i = self.target(step, exists)?; + self.at = i; + self.pages.get(i).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL: fn(PageId) -> bool = |_| true; + + fn visited(pages: &[u64]) -> PageHistory { + let mut h = PageHistory::default(); + for &p in pages { + h.visit(PageId(p), Vec2::ZERO); + } + h + } + + fn page(e: Option<(PageId, Vec2)>) -> Option { + e.map(|(p, _)| p.0) + } + + #[test] + fn go_back_and_forward_retrace_the_pages_in_the_order_they_were_viewed() { + let mut h = visited(&[1, 5, 3]); + assert!(!h.can_go(1, ALL), "nothing ahead of the last page viewed"); + assert_eq!(page(h.go(-1, ALL)), Some(5)); + assert_eq!(page(h.go(-1, ALL)), Some(1)); + assert_eq!(h.go(-1, ALL), None, "the first page viewed has nothing before it"); + assert_eq!(page(h.go(1, ALL)), Some(5)); + assert_eq!(page(h.go(1, ALL)), Some(3)); + assert_eq!(h.go(1, ALL), None); + } + + #[test] + fn going_back_does_not_record_the_page_it_shows() { + let mut h = visited(&[1, 5, 3]); + let (p, offset) = h.go(-1, ALL).unwrap(); + // The next jump starts from it. + h.leave(p, offset); + assert_eq!(page(h.go(1, ALL)), Some(3), "Go Forward still has the page left"); + } + + #[test] + fn an_entry_keeps_the_place_a_jump_left_it_at() { + let mut h = visited(&[1, 5]); + // Scrolled from page 5 to page 7, then jumped. + h.leave(PageId(7), Vec2::new(-200.0, 30.0)); + h.visit(PageId(9), Vec2::ZERO); + assert_eq!(h.go(-1, ALL), Some((PageId(7), Vec2::new(-200.0, 30.0))), "back to where the view was"); + assert_eq!(page(h.go(-1, ALL)), Some(1), "scrolling added no entry"); + assert_eq!(page(h.go(1, ALL)), Some(7)); + assert_eq!(page(h.go(1, ALL)), Some(9), "nor dropped the ones ahead"); + } + + #[test] + fn viewing_another_page_after_going_back_clears_go_forward() { + let mut h = visited(&[1, 5, 3]); + h.go(-1, ALL); + h.visit(PageId(7), Vec2::ZERO); + assert_eq!(page(h.go(-1, ALL)), Some(5), "the page gone back to stays behind the new one"); + assert_eq!(page(h.go(1, ALL)), Some(7)); + assert!(!h.can_go(1, ALL), "the page that was ahead is gone"); + } + + #[test] + fn deleted_pages_are_skipped() { + let mut h = visited(&[1, 5, 3]); + let exists = |p: PageId| p != PageId(5); + assert_eq!(page(h.go(-1, exists)), Some(1)); + assert_eq!(page(h.go(1, exists)), Some(3)); + assert!(!visited(&[5, 3]).can_go(-1, exists), "no page left to go back to"); + } + + #[test] + fn the_history_is_bounded() { + let mut h = visited(&(0..LIMIT as u64 + 10).collect::>()); + let mut back = 0; + while h.go(-1, ALL).is_some() { + back += 1; + } + assert_eq!(back, LIMIT - 1); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 5738d14c4..1fbc07ca6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -168,7 +168,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear - Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, + Effects / Clear All Transparency / Rotate 180° / Reverse Path, Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb7c5eb80..67a5e28cd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -204,7 +204,7 @@ table is the one summed. | Area | Breadth (checklist) | Ready | Hours | Main gaps | |---|---|---|---|---| | Application shell & workspace | 99% | 55% | 40–60 | context menus not opening on some systems; window controls (#333); System theme (#237); New Window/Arrange; Help menu | -| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, Go Back/Forward, layout grid (CJK) | +| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, layout grid (CJK) | | Layers | 100% | 75% | 5–10 | IDML layer order fixed 2026-10-09; Layer panel options depth | | Frames, shapes & paths | 100% | 60% | 25–40 | object copy/paste failures, reference point relocation (#337), Join/Reverse Path/Convert Point menu | | Transform | 100% | 70% | 10–20 | Rotate 180°; transform panel depth | From 3cd235ee5c02ea429125087099f95c9a2bae6e84 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 03:05:56 +0200 Subject: [PATCH 02/71] =?UTF-8?q?Type=20=E2=80=BA=20Text=20Variables=20?= =?UTF-8?q?=E2=80=BA=20Convert=20Variable=20to=20Text:=20the=20selected=20?= =?UTF-8?q?instances,=20or=20every=20instance=20of=20one=20variable,=20bec?= =?UTF-8?q?ome=20the=20text=20they=20show=20on=20their=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 1 + crates/engine/src/cmd/variables.rs | 374 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 10 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/menus.rs | 1 + docs/gaps.md | 4 +- docs/target-app-parity.md | 4 +- 16 files changed, 400 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..ea75d6063 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -351,6 +351,7 @@ Newest first. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. +- **2026-10-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index d2e513430..3f7fb2c84 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -73,9 +73,237 @@ pub fn specs() -> Vec { s.execute("text.insert", &json!({"text": c.to_string()})) } ), + cmd!( + "variables.convertToText", + "Convert Variable to Text", + ["Type", "Text Variables"], + None, + "{} — the variable instances in the selected text become plain text of the value each shows on its page; the variables stay defined", + has_selected_instance, + convert_selection + ), + cmd!( + "variables.convertAllToText", + "Convert to Text", + [], + None, + "{name} — every instance of the variable in the document becomes plain text of the value it shows on its page (Text Variables › Convert to Text); the variable stays defined", + has_doc, + convert_all + ), ] } +/// The selected text holds a variable instance (InDesign: select the variable, then Convert +/// Variable to Text). The endnote story is rebuilt from the stories' endnotes, so an edit there +/// would not last. +fn has_selected_instance(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + let st = s.active().ok_or("no document")?; + let t = st.selection.text.ok_or("no text selected")?; + let held = st.doc.endnote_story != Some(t.story) + && st + .doc + .text_story(t.story, t.cell) + .and_then(|x| x.text.get(t.range())) + .is_some_and(|x| x.chars().any(|c| designcraft_doc::vars::var_index(c).is_some())); + if held { Ok(()) } else { Err("no text variable selected".into()) } +} + +/// The page story byte `pos` is shown on, which a variable's value depends on: overset text takes +/// the page of the story's last frame; parent pages and the pasteboard have none. +fn shown_page(d: &designcraft_doc::Document, cs: &designcraft_compose::ComposedStory, pos: usize) -> Option { + designcraft_compose::vars::page_of(d, cs, pos).or_else(|| cs.frames.last().and_then(|f| d.page_of_item(f.frame))) +} + +/// Values of every variable on the pages asked for, as composition shows them (running headers +/// included), computed once per page. +struct PageValues<'a> { + doc: &'a designcraft_doc::Document, + running: std::sync::Arc, + pages: std::collections::HashMap, Vec>, +} + +impl PageValues<'_> { + fn get(&mut self, i: usize, page: Option) -> String { + let (doc, running) = (self.doc, &self.running); + let v = self.pages.entry(page).or_insert_with(|| designcraft_compose::vars::values(doc, page, Some(running))); + v.get(i).cloned().unwrap_or_default() + } +} + +/// Byte offsets of the variable instances in `range` of `text` (of variable `which`, or any), +/// with their variable index. +fn instances(text: &str, range: std::ops::Range, which: Option) -> Vec<(usize, usize)> { + let Some(t) = text.get(range.clone()) else { return Vec::new() }; + t.char_indices() + .filter_map(|(k, c)| designcraft_doc::vars::var_index(c).filter(|i| which.is_none_or(|w| w == *i)).map(|i| (range.start + k, i))) + .collect() +} + +/// Replaces the instances at `at` (byte, variable) in `st` with `value(byte)`, last first so the +/// earlier offsets hold. Returns the change in length before each replaced byte, in text order. +fn replace_instances(st: &mut designcraft_doc::Story, at: &[(usize, usize)], value: &dyn Fn(usize) -> String) -> Vec<(usize, isize)> { + let mut grown = Vec::new(); + for &(pos, i) in at.iter().rev() { + let len = var_char(i).map_or(0, char::len_utf8); + let v = value(pos); + st.replace(pos..pos + len, &v); + grown.push((pos, v.len() as isize - len as isize)); + } + grown.reverse(); + grown +} + +/// Moves byte `b` of a story by the growth of the instances replaced before it. +fn shifted(b: usize, grown: &[(usize, isize)]) -> usize { + let d: isize = grown.iter().filter(|(pos, _)| *pos < b).map(|(_, g)| g).sum(); + b.saturating_add_signed(d) +} + +/// Replaces every instance of variable `i` in `st`, its table cells and its notes with `value`. +fn replace_nested(st: &mut designcraft_doc::Story, i: usize, value: &str, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let at = instances(&st.text, 0..st.text.len(), Some(i)); + replace_instances(st, &at, &|_| value.to_string()); + let mut nested = false; + for t in st.tables.values_mut() { + if t.cells.iter().any(|c| holds(&c.text, i, depth + 1)) { + for c in &mut std::sync::Arc::make_mut(t).cells { + replace_nested(&mut c.text, i, value, depth + 1); + } + nested = true; + } + } + for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + if holds(¬e.text, i, depth + 1) { + replace_nested(&mut std::sync::Arc::make_mut(note).text, i, value, depth + 1); + nested = true; + } + } + if nested { + st.rev = st.rev.wrapping_add(1); + } +} + +/// Whether `st` or text nested in it holds an instance of variable `i`. +fn holds(st: &designcraft_doc::Story, i: usize, depth: usize) -> bool { + depth <= MAX_DEPTH + && (st.text.chars().any(|c| designcraft_doc::vars::var_index(c) == Some(i)) + || st.tables.values().any(|t| t.cells.iter().any(|c| holds(&c.text, i, depth + 1))) + || st.notes.iter().chain(st.endnotes.iter()).any(|n| holds(&n.text, i, depth + 1))) +} + +/// Byte offset in `st` of the anchor of table `id`. +fn table_anchor(st: &designcraft_doc::Story, id: u64) -> Option { + let (pi, _) = st.paras.iter().enumerate().find(|(_, p)| p.table == Some(id))?; + let r = st.para_ranges().into_iter().nth(pi)?; + Some(r.start + st.text.get(r)?.find(designcraft_doc::story::TABLE_ANCHOR)?) +} + +fn convert_selection(s: &mut Session, _: &Value) -> Result { + const ID: &str = "variables.convertToText"; + let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?; + let range = t.range(); + let (at, values) = { + let d = &s.doc()?.doc; + let st = d.story(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let text = d.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + // Text in a cell or a footnote is on the page of its table's or note's anchor. + let anchor = match t.cell { + None => None, + Some(c) if c.table == designcraft_doc::FOOTNOTE_TABLE => st.note_anchor(c.row as u64), + Some(c) => table_anchor(st, c.table), + }; + let cs = s.cache.get(d, t.story, None); + let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() }; + let at = instances(&text.text, range.clone(), None); + let values: std::collections::HashMap = + at.iter().map(|&(pos, i)| (pos, pv.get(i, shown_page(d, &cs, anchor.unwrap_or(pos))))).collect(); + (at, values) + }; + if at.is_empty() { + return Err(bad(ID, "no text variable selected")); + } + s.edit(|d, sel| { + let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let grown = replace_instances(st, &at, &|pos| values.get(&pos).cloned().unwrap_or_default()); + // The converted text stays selected. + sel.text = Some(designcraft_doc::TextSel { anchor: range.start, focus: shifted(range.end, &grown), ..t }); + Ok(json!({"converted": at.len()})) + }) +} + +/// What converting one story's instances of a variable writes: the value for each instance in +/// its own text, and for the text of each table (by id), footnote and endnote, the value on the +/// page of its anchor. +struct StoryPlan { + sid: designcraft_doc::StoryId, + own: Vec<(usize, String)>, + tables: Vec<(u64, String)>, + notes: Vec, + endnotes: Vec, +} + +fn convert_all(s: &mut Session, p: &Value) -> Result { + const ID: &str = "variables.convertAllToText"; + let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?; + let (i, plans) = { + let d = &s.doc()?.doc; + let i = d.variable(name).ok_or_else(|| bad(ID, format!("no variable `{name}`")))?; + let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() }; + let mut plans = Vec::new(); + for (sid, st) in &d.stories { + if Some(*sid) == d.endnote_story || !holds(st, i, 0) { + continue; + } + let cs = s.cache.get(d, *sid, None); + let mut value = |pos: usize| pv.get(i, shown_page(d, &cs, pos)); + let own = instances(&st.text, 0..st.text.len(), Some(i)).into_iter().map(|(pos, _)| (pos, value(pos))).collect(); + let tables = st.tables.keys().map(|id| (*id, table_anchor(st, *id).map(&mut value).unwrap_or_default())).collect(); + let notes = st.text.match_indices(designcraft_doc::FOOTNOTE_REF).map(|(k, _)| value(k)).collect(); + let endnotes = st.text.match_indices(designcraft_doc::ENDNOTE_REF).map(|(k, _)| value(k)).collect(); + plans.push(StoryPlan { sid: *sid, own, tables, notes, endnotes }); + } + (i, plans) + }; + s.edit(|d, sel| { + for plan in &plans { + let st = d.story_mut(plan.sid).ok_or(designcraft_doc::DocError::NoStory(plan.sid))?; + let at: Vec<(usize, usize)> = plan.own.iter().map(|(pos, _)| (*pos, i)).collect(); + let grown = replace_instances(st, &at, &|pos| plan.own.iter().find(|(k, _)| *k == pos).map(|(_, v)| v.clone()).unwrap_or_default()); + let mut nested = false; + for (id, v) in &plan.tables { + if let Some(t) = st.tables.get_mut(id) + && t.cells.iter().any(|c| holds(&c.text, i, 1)) + { + for c in &mut std::sync::Arc::make_mut(t).cells { + replace_nested(&mut c.text, i, v, 1); + } + nested = true; + } + } + for (note, v) in st.notes.iter_mut().zip(&plan.notes).chain(st.endnotes.iter_mut().zip(&plan.endnotes)) { + if holds(¬e.text, i, 1) { + replace_nested(&mut std::sync::Arc::make_mut(note).text, i, v, 1); + nested = true; + } + } + if nested { + st.rev = st.rev.wrapping_add(1); + } + // A selection in the story keeps its place in the text. + if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid && t.cell.is_none()) { + t.anchor = shifted(t.anchor, &grown); + t.focus = shifted(t.focus, &grown); + } + } + Ok(Value::Null) + }) +} + /// Nesting limit for tables in cells and notes (hostile documents). const MAX_DEPTH: usize = 32; @@ -265,6 +493,152 @@ mod tests { assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it"); } + /// Three pages with "Alpha" in the Heading style on page 1 and "Epsilon" on page 3, a + /// `Chapter Head` running header on Heading, and a text frame on pages 1 and 3 that each hold + /// "|" (two instances). Returns the two frames' stories. + fn running_heads() -> (Session, designcraft_doc::StoryId, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + let frame = |s: &mut Session, page: usize, y: f64, text: &str| { + let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, y, 400, y + 60.0], "content": "text", "text": text})).unwrap(); + s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story + }; + for (page, text) in [(0, "Alpha"), (2, "Epsilon")] { + let sid = frame(&mut s, page, 200.0, text); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + } + s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let both = format!("{c}|{c}"); + let (p1, p3) = (frame(&mut s, 0, 400.0, &both), frame(&mut s, 2, 400.0, &both)); + (s, p1, p3) + } + + /// Convert Variable to Text turns the selected instance into the value it shows on its own + /// page (Epsilon on page 3, not page 1's Alpha); the other instance and the variable stay. + #[test] + fn convert_variable_to_text_uses_the_instances_page() { + let (mut s, p1, p3) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let second = c.len_utf8() + 1; + s.execute("text.select", &json!({"story": p3.0, "anchor": second, "focus": second + c.len_utf8()})).unwrap(); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.story(p3).unwrap().text, format!("{c}|Epsilon"), "only the selected instance"); + assert_eq!(d.story(p1).unwrap().text, format!("{c}|{c}")); + assert!(d.variable("Chapter Head").is_some(), "the variable stays defined"); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (second, second + "Epsilon".len()), "the converted text stays selected"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(p3).unwrap().text, format!("{c}|{c}"), "one undo step"); + } + + /// The menu item is enabled only for a selection that holds an instance: not a caret, not + /// text without one. + #[test] + fn convert_variable_to_text_needs_a_selected_instance() { + let (mut s, p1, _) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let bar = c.len_utf8(); + let enabled = |s: &Session| (crate::find_command("variables.convertToText").unwrap().enabled)(s).is_ok(); + for (anchor, focus) in [(0, 0), (bar, bar + 1)] { + s.execute("text.select", &json!({"story": p1.0, "anchor": anchor, "focus": focus})).unwrap(); + assert!(!enabled(&s), "{anchor}..{focus}"); + } + s.execute("text.select", &json!({"story": p1.0, "anchor": 0, "focus": bar})).unwrap(); + assert!(enabled(&s)); + } + + /// A story threaded from a frame on page 1, which holds only "Lead", to a frame on page 3, + /// where the caret is left at the end of "Body". A cell's or a note's own byte 0 read in this + /// story is on page 1, its anchor on page 3. + fn threaded(s: &mut Session) -> designcraft_doc::StoryId { + let sp1 = s.doc().unwrap().doc.page_loc(0).unwrap().0; + let sp3 = s.doc().unwrap().doc.page_loc(2).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp1, "rect": [72, 500, 400, 520], "content": "text", "text": "Lead\nBody"})).unwrap(); + let from = r["id"].as_u64().unwrap(); + s.execute("text.thread", &json!({"from": from, "rect": [72, 500, 400, 700], "spread": sp3})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let sid = d.item(designcraft_doc::ItemId(from)).unwrap().text_frame().unwrap().story; + let cs = s.cache.get(&d, sid, None); + let page = |pos| designcraft_compose::vars::page_of(&d, &cs, pos); + assert_eq!((page(0), page("Lead\nB".len())), (Some(0), Some(2)), "Lead on page 1, Body on page 3"); + s.execute("text.select", &json!({"story": sid.0, "anchor": "Lead\nBody".len(), "focus": "Lead\nBody".len()})).unwrap(); + sid + } + + /// An instance selected in a table cell becomes the value on the page of the table's anchor. + #[test] + fn convert_variable_to_text_in_a_table_cell() { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap(); + let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap(); + let cell = designcraft_doc::CellAddr { table, row: 0, col: 0 }; + s.doc_mut().unwrap().selection = + designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: c.len_utf8(), frame: None, cell: Some(cell) }); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.text_story(sid, Some(cell)).unwrap().text, "Epsilon"); + } + + /// An instance selected in a footnote becomes the value on the page of the note's reference. + #[test] + fn convert_variable_to_text_in_a_footnote() { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap(); + let id = s.doc().unwrap().doc.story(sid).unwrap().notes[0].id; + let note = designcraft_doc::CellAddr::footnote(id); + let at = s.doc().unwrap().doc.text_story(sid, Some(note)).unwrap().text.find(c).unwrap(); + s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { + story: sid, + anchor: at, + focus: at + c.len_utf8(), + frame: None, + cell: Some(note), + }); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let text = &d.text_story(sid, Some(note)).unwrap().text; + assert!(!text.contains(c) && text.ends_with("Epsilon"), "{text:?}"); + } + + /// Text Variables › Convert to Text turns every instance of the variable into the value on + /// its page, in table cells, footnotes and endnotes on the page of their anchor, and keeps the + /// variable. + #[test] + fn convert_all_instances_to_text() { + let (mut s, p1, p3) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + let at_end = |s: &mut Session| { + let n = s.doc().unwrap().doc.story(sid).unwrap().len(); + s.execute("text.select", &json!({"story": sid.0, "anchor": n, "focus": n})).unwrap(); + }; + s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap(); + at_end(&mut s); + s.execute("endnote.insert", &json!({"text": c.to_string()})).unwrap(); + at_end(&mut s); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap(); + s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.story(p1).unwrap().text, "Alpha|Alpha"); + assert_eq!(d.story(p3).unwrap().text, "Epsilon|Epsilon"); + let st = d.story(sid).unwrap(); + assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "Epsilon", "table cell"); + assert!(st.notes[0].text.text.ends_with("Epsilon"), "footnote: {:?}", st.notes[0].text.text); + assert!(st.endnotes[0].text.text.ends_with("Epsilon"), "endnote: {:?}", st.endnotes[0].text.text); + assert!(d.variable("Chapter Head").is_some(), "the variable stays defined"); + } + /// Deleting a variable flattens its instances in table cells too and moves the instances of /// later variables down, so cells keep showing the same text. #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6057abc97..2d314af19 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1852,6 +1852,16 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Insert Pages…", ["Insert Pages…", "Insérer des pages…", "Insert Pages…", "Insert Pages…", "插入页面…"]), ("Insert Variable", ["Insert Variable", "Insérer une variable", "Insert Variable", "Insert Variable", "插入变量"]), + ( + "Convert Variable to Text", + [ + "Variable in Text konvertieren", + "Convertir la variable en texte", + "Convertir variable en texto", + "変数をテキストに変換", + "将变量转换为文本", + ], + ), ("Inspect Document", ["Inspect Document", "Inspecter le document", "Inspect Document", "Inspect Document", "检查文档"]), ( "Interface Color Theme", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..fc91462dd 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1411,6 +1411,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "إدراج تعبير رياضي…"), ("Insert Pages…", "إدراج صفحات…"), ("Insert Variable", "إدراج متغير"), + ("Convert Variable to Text", "تحويل المتغير إلى نص"), ("Inspect Document", "فحص المستند"), ("Interface Color Theme", "نسق ألوان الواجهة"), ("Join the ArtCraft Discord…", "الانضمام إلى Discord الخاص بـ ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..fcc4488d9 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -560,6 +560,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Vložit matematický výraz…"), ("Insert Pages…", "Vložit stránky…"), ("Insert Variable", "Vložit proměnnou"), + ("Convert Variable to Text", "Převést proměnnou na text"), ("Inspect Document", "Prozkoumat dokument"), ("Interface Color Theme", "Barevný motiv rozhraní"), ("Join the ArtCraft Discord…", "Připojit se k Discordu ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 4f4177cbd..58554f490 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -611,6 +611,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Mathematischen Ausdruck einfügen …"), ("Insert Pages…", "Seiten einfügen …"), ("Insert Variable", "Variable einfügen"), + ("Convert Variable to Text", "Variable in Text konvertieren"), ("Inspect Document", "Dokument untersuchen"), ("Interface Color Theme", "Farbschema der Oberfläche"), ("Join the ArtCraft Discord…", "Dem ArtCraft-Discord beitreten …"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..b4478614b 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -688,6 +688,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Inserisci espressione matematica…"), ("Insert Pages…", "Inserisci pagine…"), ("Insert Variable", "Inserisci variabile"), + ("Convert Variable to Text", "Converti variabile in testo"), ("Inspect Document", "Ispeziona documento"), ("Interface Color Theme", "Tema colore dell'interfaccia"), ("Join the ArtCraft Discord…", "Unisciti al Discord di ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index e23ef15c7..adaf4af15 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -803,6 +803,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "数式を挿入…"), ("Insert Pages…", "ページを挿入…"), ("Insert Variable", "変数を挿入"), + ("Convert Variable to Text", "変数をテキストに変換"), ("Inspect Document", "ドキュメントを調査"), ("Interface Color Theme", "インターフェイスのカラーテーマ"), ("Join the ArtCraft Discord…", "ArtCraft の Discord に参加…"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..46efc08da 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -616,6 +616,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "მათემატიკური გამოსახულების ჩასმა…"), ("Insert Pages…", "გვერდების ჩასმა…"), ("Insert Variable", "ცვლადის ჩასმა"), + ("Convert Variable to Text", "ცვლადის ტექსტად გარდაქმნა"), ("Inspect Document", "დოკუმენტის შემოწმება"), ("Interface Color Theme", "ინტერფეისის ფერთა თემა"), ("Join the ArtCraft Discord…", "შემოუერთდით ArtCraft-ის Discord-ს…"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..867bfa164 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -567,6 +567,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Wstaw wyrażenie matematyczne…"), ("Insert Pages…", "Wstaw strony…"), ("Insert Variable", "Wstaw zmienną"), + ("Convert Variable to Text", "Zamień zmienną na tekst"), ("Inspect Document", "Sprawdź dokument"), ("Interface Color Theme", "Motyw kolorystyczny interfejsu"), ("Join the ArtCraft Discord…", "Dołącz do Discorda ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..275a76acc 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1544,6 +1544,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Cross-Reference…", "Inserir referência cruzada…"), ("Insert Math Expression…", "Inserir expressão matemática…"), ("Insert Variable", "Inserir variável"), + ("Convert Variable to Text", "Converter variável em texto"), ("Inspect Document", "Inspecionar documento"), ("Interface Color Theme", "Tema de cores da interface"), ("Join the ArtCraft Discord…", "Entrar no Discord do ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..0646badb1 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -547,6 +547,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Вставить математическое выражение…"), ("Insert Pages…", "Вставить страницы…"), ("Insert Variable", "Вставить переменную"), + ("Convert Variable to Text", "Преобразовать переменную в текст"), ("Inspect Document", "Проверить документ"), ("Interface Color Theme", "Цветовая тема интерфейса"), ("Join the ArtCraft Discord…", "Присоединиться к Discord ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..493ed6dc9 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -691,6 +691,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Вставити математичний вираз…"), ("Insert Pages…", "Вставити сторінки…"), ("Insert Variable", "Вставити змінну"), + ("Convert Variable to Text", "Перетворити змінну на текст"), ("Inspect Document", "Перевірити документ"), ("Interface Color Theme", "Колірна тема інтерфейсу"), ("Join the ArtCraft Discord…", "Приєднатися до Discord ArtCraft…"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd7..181812224 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -507,6 +507,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ ">Text Variables", "cmd:variables.define", "cmd:variables.insert", + "cmd:variables.convertToText", "<", "-", ">Bulleted & Numbered Lists", diff --git a/docs/gaps.md b/docs/gaps.md index 5738d14c4..7352bfd17 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (gap 16: Convert Variable to Text; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -167,7 +167,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 16. Menu items still missing (U) - **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference - update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear + update commands, Convert Bullets/Numbering to Text, Object ▸ Clear Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb7c5eb80..03cdf2564 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Convert Variable to Text; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -446,7 +446,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Based on Parent (`layout.parents.options {parent, basedOn}`): a parent shows its base parent's items under its own, on its pages and on the parent itself; chains work, cycles are refused, and items from any parent in the chain can be overridden. IDML carries it as the parent pages' `AppliedMaster`; a cycle or missing base in a file is cleared with a warning on open. The Pages panel has no Parent Options dialog yet. - Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `` in IDML. - Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. -- Table of contents (Layout ▸ Table of Contents dialog: included styles, per-style entry style, page number position and its character style, separator and its character style, alphabetical sorting and level, title style, Replace Existing; generate/update, dot leaders; the definition round-trips through IDML as a TOC style) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. +- Table of contents (Layout ▸ Table of Contents dialog: included styles, per-style entry style, page number position and its character style, separator and its character style, alphabetical sorting and level, title style, Replace Existing; generate/update, dot leaders; the definition round-trips through IDML as a TOC style) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page; Convert Variable to Text turns the selected instances, or every instance of one variable, into the text they show on their page. - Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather. - ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG. - MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input. From 0160b7af07f4fd7796037ab55a1747c12cd42959 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 10:56:22 +0200 Subject: [PATCH 03/71] Convert to Text (all instances): a caret in a table cell or a footnote keeps its place after the converted value; gaps: 150 missing menu items --- crates/engine/src/cmd/variables.rs | 53 ++++++++++++++++++++++++++++-- docs/gaps.md | 2 +- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 3f7fb2c84..17f0671e6 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -162,12 +162,13 @@ fn shifted(b: usize, grown: &[(usize, isize)]) -> usize { } /// Replaces every instance of variable `i` in `st`, its table cells and its notes with `value`. -fn replace_nested(st: &mut designcraft_doc::Story, i: usize, value: &str, depth: usize) { +/// Returns the growth of `st`'s own text, as [`replace_instances`]. +fn replace_nested(st: &mut designcraft_doc::Story, i: usize, value: &str, depth: usize) -> Vec<(usize, isize)> { if depth > MAX_DEPTH { - return; + return Vec::new(); } let at = instances(&st.text, 0..st.text.len(), Some(i)); - replace_instances(st, &at, &|_| value.to_string()); + let grown = replace_instances(st, &at, &|_| value.to_string()); let mut nested = false; for t in st.tables.values_mut() { if t.cells.iter().any(|c| holds(&c.text, i, depth + 1)) { @@ -186,6 +187,7 @@ fn replace_nested(st: &mut designcraft_doc::Story, i: usize, value: &str, depth: if nested { st.rev = st.rev.wrapping_add(1); } + grown } /// Whether `st` or text nested in it holds an instance of variable `i`. @@ -271,6 +273,23 @@ fn convert_all(s: &mut Session, p: &Value) -> Result { }; s.edit(|d, sel| { for plan in &plans { + // A selection in a table cell or a footnote keeps its place in that text: it is + // converted first, so the loop below finds nothing left there. + if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid) + && let Some(c) = t.cell + { + let value = match c.footnote_id() { + Some(id) => d.story(plan.sid).and_then(|st| st.notes.iter().position(|n| n.id == id)).and_then(|k| plan.notes.get(k)), + None => plan.tables.iter().find(|(id, _)| *id == c.table).map(|(_, v)| v), + }; + if let Some(v) = value + && let Some(text) = d.text_story_mut(plan.sid, Some(c)) + { + let grown = replace_nested(text, i, v, 1); + t.anchor = shifted(t.anchor, &grown); + t.focus = shifted(t.focus, &grown); + } + } let st = d.story_mut(plan.sid).ok_or(designcraft_doc::DocError::NoStory(plan.sid))?; let at: Vec<(usize, usize)> = plan.own.iter().map(|(pos, _)| (*pos, i)).collect(); let grown = replace_instances(st, &at, &|pos| plan.own.iter().find(|(k, _)| *k == pos).map(|(_, v)| v.clone()).unwrap_or_default()); @@ -639,6 +658,34 @@ mod tests { assert!(d.variable("Chapter Head").is_some(), "the variable stays defined"); } + /// A caret in a table cell or a footnote after an instance stays after the value Convert to + /// Text writes there, so typing lands after it. + #[test] + fn convert_all_keeps_the_caret_in_a_cell_or_a_footnote() { + for footnote in [false, true] { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + let text = format!("{c}|"); + let at = if footnote { + s.execute("footnote.insert", &json!({"text": text})).unwrap(); + designcraft_doc::CellAddr::footnote(s.doc().unwrap().doc.story(sid).unwrap().notes[0].id) + } else { + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap(); + let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap(); + designcraft_doc::CellAddr { table, row: 0, col: 0 } + }; + let n = s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text.len(); + s.doc_mut().unwrap().selection = + designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: n, focus: n, frame: None, cell: Some(at) }); + s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap(); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + let text = &s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text; + assert!(text.ends_with("Epsilon|x"), "footnote {footnote}: {text:?}"); + } + } + /// Deleting a variable flattens its instances in table cells too and moves the instances of /// later variables down, so cells keep showing the same text. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 7352bfd17..70e680453 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,7 +165,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 30–50 h. ### 16. Menu items still missing (U) -- **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes +- **Missing:** 150 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference update commands, Convert Bullets/Numbering to Text, Object ▸ Clear Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, From 5539e286475bba2cfe7084cba8ae7352f6070fa5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 11:26:32 +0200 Subject: [PATCH 04/71] Layout: Go Back after Presentation returns to the last spread presented Presentation's spreads now count as one jump in the window's history, and leaving it as a jump back to the view it was entered from, at the place the view was. Before, the history stayed on the last presented spread while the view was put back, so Go Back couldn't reach it. docs/gaps.md: Go Back and Go Forward are two of the missing menu items, 149. --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/lib.rs | 8 ++++ crates/ui-egui/src/menus.rs | 77 +++++++++++++++++++++++++++++++++++- docs/gaps.md | 2 +- 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index dddcfedbe..e0ac7344a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -262,7 +262,7 @@ fn page_ids(doc: &Document) -> Vec { /// The page in view and the view centre's offset from that page's centre. Parent pages have none, /// nor has a view still waiting to be fitted (leaving parent pages for a document page). -fn page_in_view(app: &DesignApp) -> Option<(designcraft_doc::ids::PageId, DVec2)> { +pub(crate) fn page_in_view(app: &DesignApp) -> Option<(designcraft_doc::ids::PageId, DVec2)> { let st = app.session.active().filter(|st| !st.editing_parents)?; let (v, r) = (app.view().filter(|v| v.fitted)?, app.canvas_rect?); let abs = current_page(app)?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b1add9ac..e144eb418 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -194,6 +194,12 @@ pub struct UiState { /// The view (by its `views` key) Presentation was entered from; leaving puts it back. #[serde(skip)] pub presentation_view: Option<(u64, View)>, + /// The page in that view and the place on it, for Go Back after leaving Presentation. + #[serde(skip)] + pub presentation_page: Option<(designcraft_doc::ids::PageId, designcraft_geom::Vec2)>, + /// That view's Go Back / Go Forward history; leaving puts it back with one jump added. + #[serde(skip)] + pub(crate) presentation_history: Option, pub frame_edges: bool, pub rulers: bool, pub guides: bool, @@ -362,6 +368,8 @@ impl Default for UiState { screen_mode: ScreenMode::Normal, presentation_return: ScreenMode::Normal, presentation_view: None, + presentation_page: None, + presentation_history: None, frame_edges: true, rulers: true, guides: true, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2a3ffebc2..45becf63d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1978,13 +1978,28 @@ fn set_screen_mode(app: &mut DesignApp, mode: ScreenMode) { app.ui.presentation_return = app.ui.screen_mode; // Presentation fits the spread to the screen; leaving it puts the user's view back. app.ui.presentation_view = app.view_key().zip(app.view().copied()); + app.ui.presentation_page = crate::canvas::page_in_view(app); + app.ui.presentation_history = app.page_history().cloned(); } if mode != ScreenMode::Presentation && presenting && let Some((k, v)) = app.ui.presentation_view.take() - && let Some(view) = app.views.get_mut(&k) + && app.views.contains_key(&k) { - *view = v; + // Go Back sees the presentation as one jump to the last spread presented, and putting the + // view back as a jump from there: the spreads in between would otherwise have replaced the + // place the view was at with the presentation's. + let shown = app.ui.presentation_page.take().zip(crate::canvas::page_in_view(app)).filter(|_| app.view_key() == Some(k)); + let h = app.page_history.entry(k).or_default(); + *h = app.ui.presentation_history.take().unwrap_or_default(); + if let Some(((from, at), (to, offset))) = shown { + h.leave(from, at); + h.visit(to, offset); + h.visit(from, at); + } + if let Some(view) = app.views.get_mut(&k) { + *view = v; + } } app.ui.screen_mode = mode; } @@ -4346,6 +4361,64 @@ mod tests { assert_eq!(spread(&app), 2); } + #[test] + fn go_back_after_presentation_returns_to_the_last_spread_presented() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let spread = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap() + }; + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // Scrolled off the page's centre, so the place Go Back returns to is not just the page. + let view = app.view_mut().unwrap(); + view.origin.x += 40.0; + view.origin.y += 60.0; + frame(&mut app, vec![]); + assert_eq!(spread(&app), 0); + let before = *app.view().unwrap(); + // Presentation goes on to spreads 1 and 2; leaving it puts spread 0 back as it was. + app.run("view.screenMode", json!({"mode": "presentation"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(spread(&app), 2); + app.run("view.screenMode", json!({"mode": "normal"})).unwrap(); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 0); + assert!(!menu_enabled(&app, "view.goForward")); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(spread(&app), 2, "Go Back shows the last spread presented"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(spread(&app), 0, "Go Back again, the view Presentation was entered from"); + let v = *app.view().unwrap(); + assert!((v.origin - before.origin).hypot() < 1e-6, "where it was: {:?} vs {:?}", v.origin, before.origin); + assert!(!menu_enabled(&app, "view.goBack"), "the presentation is one jump: spread 1 isn't kept"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(spread(&app), 2, "Go Forward"); + } + #[test] fn presentation_shows_one_spread_and_goes_through_them_with_keys_and_clicks() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/gaps.md b/docs/gaps.md index 1fbc07ca6..4ee123d03 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,7 +165,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 30–50 h. ### 16. Menu items still missing (U) -- **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes +- **Missing:** 149 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear Effects / Clear All Transparency / Rotate 180° / Reverse Path, From 13f6739f49efc2e54cabdb8460722ee7e38fffc0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 15:39:47 +0200 Subject: [PATCH 05/71] Layout: Next or Previous Spread at the last or first spread is no jump for Go Back At the end of the document the spread command only centres the spread in view, which can move the view from one facing page to the other. That move was recorded as a jump, so Go Back went to the other page of the same spread instead of the page viewed before. --- crates/ui-egui/src/canvas.rs | 21 ++++++++++++++---- crates/ui-egui/src/menus.rs | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e0ac7344a..721e46469 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -326,16 +326,29 @@ fn present_fit(app: &mut DesignApp, rect: Rect) { /// Centre the spread `delta` spreads after (or before) the one in view (keeps zoom); stops at the /// first and last spread. Parent spreads while they are being edited. pub fn step_spread(app: &mut DesignApp, delta: isize) { - note_jump(app, false); + // At the first or last spread only the view moves, within the spread: no jump for Go Back. + let jump = spread_step(app, delta).is_some_and(|(cur, to)| cur != to); + if jump { + note_jump(app, false); + } centre_spread(app, delta); - note_jump(app, true); + if jump { + note_jump(app, true); + } +} + +/// The slot in view and the one `delta` spreads after (or before) it, stopping at the ends. +fn spread_step(app: &DesignApp, delta: isize) -> Option<(usize, usize)> { + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cur = current_slot(app, &layout)?; + Some((cur, cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1)))) } fn centre_spread(app: &mut DesignApp, delta: isize) { + let Some((_, to)) = spread_step(app, delta) else { return }; let Some(st) = app.session.active() else { return }; let layout = CanvasLayout::new(&st.doc, st.editing_parents); - let Some(cur) = current_slot(app, &layout) else { return }; - let to = cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1)); let Some(b) = layout.slots.get(to).map(|s| s.bounds) else { return }; let Some(rect) = app.canvas_rect else { return }; if let Some(v) = app.view_mut() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 45becf63d..d6bd7cfec 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -4289,6 +4289,47 @@ mod tests { assert_eq!(page(&app), 4, "Go Back returns to the page before Next Page"); } + #[test] + fn next_spread_at_the_last_spread_is_no_jump() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap(); + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + let alt = egui::Modifiers::ALT; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // Pages 1 | 2-3 | 4-5 | 6-7: page 6 is the left page of the last spread. + app.run("view.goToPage", json!({"page": 6})).unwrap(); + frame(&mut app, vec![]); + assert_eq!(page(&app), 5); + // Next Spread has no spread to go to; centring the last one moves the view to its fold. + frame(&mut app, key(egui::Key::PageDown, alt)); + frame(&mut app, key(egui::Key::PageDown, alt)); + assert_ne!(page(&app), 5, "the view moved to the right page"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 0, "Go Back returns to the page before Go to Page"); + assert!(!menu_enabled(&app, "view.goBack")); + } + #[test] fn go_back_returns_to_the_view_a_jump_left_and_scrolling_adds_no_entry() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From a0790bcb55e4dd20ccb66d7ff1d6f7f81d653de5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 18:08:05 +0200 Subject: [PATCH 06/71] Layout: Go Back and Go Forward wait for the view to be fitted --- crates/ui-egui/src/canvas.rs | 7 +++++++ crates/ui-egui/src/menus.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 721e46469..31362868c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -284,6 +284,10 @@ fn note_jump(app: &mut DesignApp, to: bool) { /// Whether Layout ▸ Go Back (`step` < 0) or Go Forward has a page to go to from the view as it is. pub fn can_go_through_history(app: &DesignApp, step: isize) -> bool { let Some(st) = app.session.active().filter(|st| !st.editing_parents) else { return false }; + // A view still waiting to be fitted is refitted next frame, which would replace the destination. + if !app.view().is_some_and(|v| v.fitted) { + return false; + } let ids = page_ids(&st.doc); let Some(mut h) = app.page_history().cloned() else { return false }; if let Some((id, offset)) = page_in_view(app) { @@ -295,6 +299,9 @@ pub fn can_go_through_history(app: &DesignApp, step: isize) -> bool { /// Layout ▸ Go Back (`step` < 0) or Go Forward: show the page viewed before or after this one, where /// the view was on it (zoom kept). pub fn go_through_history(app: &mut DesignApp, step: isize) { + if !app.view().is_some_and(|v| v.fitted) { + return; + } note_jump(app, false); let Some(ids) = app.session.active().filter(|st| !st.editing_parents).map(|st| page_ids(&st.doc)) else { return }; let Some((id, offset)) = app.page_history_mut().and_then(|h| h.go(step, |id| ids.contains(&id))) else { return }; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d6bd7cfec..8c0b3af46 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -4289,6 +4289,33 @@ mod tests { assert_eq!(page(&app), 4, "Go Back returns to the page before Next Page"); } + #[test] + fn go_back_waits_for_the_view_to_be_fitted() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp| { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + frame(&mut app); + frame(&mut app); + app.run("view.goToPage", json!({"page": 5})).unwrap(); + frame(&mut app); + assert!(menu_enabled(&app, "view.goBack")); + // Leaving parent pages through the Pages panel: the canvas refits on the next frame. + app.view_mut().unwrap().fitted = false; + assert!(!menu_enabled(&app, "view.goBack"), "the refit would replace where Go Back went"); + crate::canvas::go_through_history(&mut app, -1); + frame(&mut app); + assert!(menu_enabled(&app, "view.goBack"), "the unfitted Go Back used no history entry"); + } + #[test] fn next_spread_at_the_last_spread_is_no_jump() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 7362530591265a931b624524038c960c766be51d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 20:30:07 +0200 Subject: [PATCH 07/71] MCP: --automation-read-root and --automation-write-root may each be given more than once (#1552) A path passes when it lies inside one of the roots of its kind. The roots' common parent, a sibling named like a root, `..` or a link out of them and the other kind's roots stay refused; a folder given twice counts once. --- ROADMAP.md | 1 + apps/designcraft-cli/src/automation_args.rs | 56 +++--- apps/designcraft-cli/src/main.rs | 15 +- apps/designcraft-cli/tests/it/cli.rs | 78 +++++++- crates/engine/src/file_access.rs | 210 ++++++++++++++++---- crates/engine/src/tests_roots.rs | 2 +- crates/mcp/tests/it/automation_roots.rs | 4 +- docs/mcp.md | 29 ++- 8 files changed, 300 insertions(+), 95 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..588c46923 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. diff --git a/apps/designcraft-cli/src/automation_args.rs b/apps/designcraft-cli/src/automation_args.rs index 52ef34f20..2ad9af786 100644 --- a/apps/designcraft-cli/src/automation_args.rs +++ b/apps/designcraft-cli/src/automation_args.rs @@ -3,30 +3,22 @@ use std::path::PathBuf; +#[derive(Default)] pub struct AutomationArgs { - pub read_root: Option, - pub write_root: Option, - read_given: bool, - write_given: bool, + pub read_roots: Vec, + pub write_roots: Vec, } 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. + /// Returns whether this argument is a root flag. Each flag may be given more than once (#1552), + /// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder, none replaces one. 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), + let roots = match flag { + "--automation-read-root" => &mut self.read_roots, + "--automation-write-root" => &mut self.write_roots, _ => return Ok(false), }; - if *given { - return Err(format!("{flag} given twice")); - } let value = match inline { Some(value) => value.to_owned(), None => { @@ -40,8 +32,7 @@ impl AutomationArgs { if value.is_empty() { return Err(format!("{flag} needs a non-empty folder")); } - *root = Some(value.into()); - *given = true; + roots.push(value.into()); Ok(true) } } @@ -51,40 +42,43 @@ mod tests { use super::*; #[test] - fn roots_are_taken_once_and_other_arguments_are_left() { - let mut roots = AutomationArgs::new(None, None); + fn roots_add_up_and_other_arguments_are_left() { + let mut roots = AutomationArgs::default(); 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!(roots.read_roots, [PathBuf::from("a folder")]); + assert_eq!(roots.write_roots, [PathBuf::from("out")]); 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())); + // #1552: a flag given again adds a folder, in either spelling, and keeps the first. + assert!(roots.consume("--automation-read-root=other", &mut args).unwrap()); + assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap()); + assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]); + assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]); } #[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 roots = AutomationArgs { read_roots: vec!["read".into()], write_roots: vec!["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())); + assert_eq!(roots.read_roots, [PathBuf::from("read")]); + assert_eq!(roots.write_roots, [PathBuf::from("write")]); } - let mut roots = AutomationArgs::new(None, None); + let mut roots = AutomationArgs::default(); 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); + let mut roots = AutomationArgs::default(); 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())); + assert_eq!(roots.read_roots, [PathBuf::from("-folder")]); + assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]); } } diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 71e05a480..99dd49afe 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -10,7 +10,7 @@ //! # 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] [--automation-read-root DIR] [--automation-write-root DIR] +//! 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 @@ -39,7 +39,7 @@ 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] [--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"; +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 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. Each may be given more than once; a kind\n 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"; /// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] @@ -170,7 +170,7 @@ fn mcp(args: &[String]) -> Result<(), String> { use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr}; let mut connect: Option = None; let mut sample = false; - let mut roots_args = automation_args::AutomationArgs::new(None, None); + let mut roots_args = automation_args::AutomationArgs::default(); let mut it = args.iter().cloned(); while let Some(a) = it.next() { if roots_args.consume(&a, &mut it)? { @@ -181,12 +181,14 @@ fn mcp(args: &[String]) -> Result<(), String> { "--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])" + "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())?; + let read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect(); + let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect(); + let roots = AutomationRoots::new(&read, &write)?; 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)" @@ -209,8 +211,7 @@ so drop --connect (or use --connect without the roots)" 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())); + eprintln!("designcraft-cli: files confined: {}", roots.describe()); h = h.with_automation_roots(Some(roots)); } Box::new(h) diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs index 173a605f4..467b231f6 100644 --- a/apps/designcraft-cli/tests/it/cli.rs +++ b/apps/designcraft-cli/tests/it/cli.rs @@ -401,6 +401,76 @@ fn mcp_automation_roots_confine_the_server() { let _ = std::fs::remove_dir_all(base); } +/// #1552: each root flag may be given more than once. A path inside any root of its kind passes; +/// the roots' common parent, a sibling whose name starts like a root's, `..` out of a root, the +/// other kind's roots and a link out of a root stay refused. +#[test] +fn mcp_automation_roots_take_several_folders() { + let base = std::env::temp_dir().join(format!("dc-cli-roots-several-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "shared", "out", "scratch", "outside", "in2", "scratch2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + // Resolved, as the server shows the roots (`/var` is `/private/var` on macOS). + let base = std::fs::canonicalize(base).unwrap(); + for f in ["in/a.designcraft", "shared/b.designcraft", "outside/secret.designcraft", "parent.designcraft", "in2/c.designcraft"] { + make_document(&base.join(f)); + } + let p = |f: &str| base.join(f).to_str().unwrap().to_string(); + let sep = std::path::MAIN_SEPARATOR; + let up = |root: &str, rest: &str| format!("{}{sep}..{sep}{rest}", p(root)); + let calls = [ + ("open_document", serde_json::json!({"path": p("in/a.designcraft")})), + ("open_document", serde_json::json!({"path": p("shared/b.designcraft")})), + // The roots' common parent is not a root. + ("open_document", serde_json::json!({"path": p("parent.designcraft")})), + ("open_document", serde_json::json!({"path": up("shared", &format!("outside{sep}secret.designcraft"))})), + // `in2` only starts like `in`. + ("open_document", serde_json::json!({"path": p("in2/c.designcraft")})), + // `..` from one read root into another stays inside. + ("open_document", serde_json::json!({"path": up("shared", &format!("in{sep}a.designcraft"))})), + ("save_document", serde_json::json!({"path": p("out/x.designcraft")})), + ("save_document", serde_json::json!({"path": p("scratch/y.designcraft")})), + ("save_document", serde_json::json!({"path": p("z.designcraft")})), + ("save_document", serde_json::json!({"path": p("scratch2/z.designcraft")})), + // Read and write stay separate: a read root is not a write root, nor the other way round. + ("save_document", serde_json::json!({"path": p("shared/w.designcraft")})), + ("open_document", serde_json::json!({"path": p("out/x.designcraft")})), + ("export_png", serde_json::json!({"path": up("scratch", &format!("outside{sep}p.png"))})), + ]; + let read2 = format!("--automation-read-root={}", p("shared")); + let args = ["--automation-read-root", &p("in"), &read2, "--automation-write-root", &p("out"), "--automation-write-root", &p("scratch")]; + let (success, stderr, replies) = mcp_session(&args, &calls); + assert!(success, "{stderr}"); + let confined = format!("files confined: read {}, {}; write {}, {}", p("in"), p("shared"), p("out"), p("scratch")); + assert!(stderr.contains(&confined), "{stderr}"); + let errors: Vec = replies.iter().map(|r| r["result"]["isError"] == true).collect(); + assert_eq!(errors, [false, false, true, true, true, false, false, false, true, true, true, true, true], "{replies:?}"); + for (i, kind) in [(2, "read"), (3, "read"), (4, "read"), (8, "write"), (9, "write"), (10, "write"), (11, "read"), (12, "write")] { + assert!(text(&replies[i]).contains(&format!("outside the {kind} roots ")), "{i}: {}", text(&replies[i])); + } + assert!(base.join("out/x.designcraft").is_file() && base.join("scratch/y.designcraft").is_file()); + for f in ["z.designcraft", "scratch2/z.designcraft", "shared/w.designcraft", "outside/p.png"] { + assert!(!base.join(f).exists(), "{f} was written outside the write roots"); + } + + // A link in one root that leads out of all of them is outside, for reading and for writing. + #[cfg(unix)] + { + std::os::unix::fs::symlink(base.join("outside/secret.designcraft"), base.join("shared/leak.designcraft")).unwrap(); + std::os::unix::fs::symlink(base.join("outside"), base.join("scratch/away")).unwrap(); + let calls = [ + ("open_document", serde_json::json!({"path": p("shared/leak.designcraft")})), + ("save_document", serde_json::json!({"path": p("scratch/away/x.designcraft")})), + ]; + let (_, _, replies) = mcp_session(&args, &calls); + assert!(text(&replies[0]).contains("outside the read roots"), "{}", text(&replies[0])); + assert!(text(&replies[1]).contains("outside the write roots"), "{}", text(&replies[1])); + assert!(!base.join("outside/x.designcraft").exists()); + } + 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())); @@ -419,9 +489,11 @@ fn mcp_automation_root_flags_are_checked_up_front() { // 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"); + // #1552: a flag may be given again, but every folder it names must exist and be given. + fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); + fails(&["--automation-write-root", missing.to_str().unwrap(), "--automation-write-root", dir], "cannot open the automation write root"); + fails(&["--automation-write-root", dir, "--automation-write-root"], "needs a folder"); + fails(&["--automation-read-root", dir, "--automation-read-root="], "needs a non-empty folder"); 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"); diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 4f9a17c6f..31d61e382 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -1,15 +1,17 @@ //! 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`]: +//! files an agent's commands reach; each flag may be given more than once (#1552). [`AutomationRoots`] +//! holds the folders of each kind. 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. +//! - The result must lie inside one of the roots of its kind, compared by whole components +//! (`/work2` isn't inside `/work`), without regard to case on Windows. Two roots don't make their +//! common parent one. //! - 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 @@ -32,11 +34,11 @@ pub enum Access { Write, } -/// The folders automation may read and write; a root left out grants no access of its kind. +/// The folders automation may read and write; a kind with no root grants no access of its kind. #[derive(Debug)] pub struct AutomationRoots { - read: Option, - write: Option, + read: Vec, + write: Vec, } #[derive(Debug)] @@ -62,47 +64,61 @@ impl Root { } 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() { + /// The roots `read` and `write`, which must be existing folders (one given twice counts once); + /// `None` when neither kind has any (nothing is confined then). + pub fn new(read: &[&Path], write: &[&Path]) -> Result>, String> { + if read.is_empty() && write.is_empty() { 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 }))) + let open = |paths: &[&Path], access: Access| -> Result, String> { + let mut roots: Vec = Vec::with_capacity(paths.len()); + for path in paths { + let root = Root::open(path, access)?; + if !roots.iter().any(|r| r.resolved == root.resolved) { + roots.push(root); + } + } + Ok(roots) + }; + Ok(Some(Arc::new(Self { read: open(read, Access::Read)?, write: open(write, Access::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 roots for people: `read A, B; write C` (`none` for a kind without any), absolute with + /// their links followed. + pub fn describe(&self) -> String { + let list = |roots: &[Root]| { + if roots.is_empty() { + return "none".to_string(); + } + roots.iter().map(|r| r.shown.display().to_string()).collect::>().join(", ") + }; + format!("read {}; write {}", list(&self.read), list(&self.write)) } - /// 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. + /// May the file or folder at `path` be read (or written)? It must lie inside one of the roots + /// of that kind. `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 roots = match access { + Access::Read => &self.read, + Access::Write => &self.write, }; - let Some(Root { resolved: folder, shown }) = root else { + if roots.is_empty() { 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) { + } + let resolved = resolve(path)?; + if roots.iter().any(|r| inside(&resolved, &r.resolved)) { 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())), + let shown: Vec = roots.iter().map(|r| r.shown.display().to_string()).collect(); + match (shown.as_slice(), access) { + ([root], Access::Read) => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())), + ([root], Access::Write) => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())), + (_, Access::Read) => Err(format!("automation path rejected: outside the read roots {}: {}", shown.join(", "), path.display())), + (_, Access::Write) => Err(format!("automation path rejected: outside the write roots {}: {}", shown.join(", "), path.display())), } } } @@ -279,7 +295,7 @@ mod tests { } 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(); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap(); (base, roots) } @@ -339,18 +355,18 @@ mod tests { #[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(); + let e = AutomationRoots::new(&[&base.join("nope")], &[]).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(); + let e = AutomationRoots::new(&[], &[&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()); + assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(&[], &[]).unwrap().is_none()); // One root alone grants only its own access. - let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let read_only = AutomationRoots::new(&[&base.join("in")], &[]).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); @@ -387,11 +403,125 @@ mod tests { 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(); + let linked = AutomationRoots::new(&[&base.join("in-link")], &[]).unwrap().unwrap(); assert_eq!(linked.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); let _ = std::fs::remove_dir_all(base); } + /// `in` and `shared` (read roots), `out` and `scratch` (write roots) beside `outside`, the + /// siblings `in2` and `scratch2` whose names start like a root's, and a file in their parent. + /// The folder is returned resolved (`/var` is `/private/var` on macOS), as the roots show it, so + /// a path that passes only because it was resolved is the case being tested and nothing else. + fn several(name: &str) -> (PathBuf, Arc) { + let (base, _) = folders(name); + let base = std::fs::canonicalize(base).unwrap(); + for d in ["shared", "scratch", "scratch2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + for f in ["shared/b.designcraft", "in2/c.designcraft", "parent.designcraft"] { + std::fs::write(base.join(f), "x").unwrap(); + } + let roots = AutomationRoots::new(&[&base.join("in"), &base.join("shared")], &[&base.join("out"), &base.join("scratch")]).unwrap().unwrap(); + (base, roots) + } + + /// #1552: several roots of a kind: a path inside any of them passes, one inside none is refused + /// naming them all, and read and write stay separate. + #[test] + fn several_roots_of_a_kind_each_grant_their_access() { + let (base, roots) = several("several"); + assert_eq!(roots.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(s(base.join("shared/b.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(s(base.join("out/x.png")), Access::Write), Ok(())); + assert_eq!(roots.check(s(base.join("scratch/new/x.png")), Access::Write), Ok(())); + let e = roots.check(s(base.join("outside/secret.designcraft")), Access::Read).unwrap_err(); + let listed = format!("outside the read roots {}, {}: ", s(base.join("in")), s(base.join("shared"))); + assert!(e.starts_with("automation path rejected: ") && e.contains(&listed), "{e}"); + assert!(roots.check(s(base.join("outside/x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + // Two sibling roots don't make their parent a root. + assert!(roots.check(s(base.join("parent.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.clone()), Access::Read).is_err()); + assert!(roots.check(s(base.join("x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + // Read and write stay separate: a read root is not a write root, nor the other way round. + assert!(roots.check(s(base.join("shared/b.designcraft")), Access::Write).unwrap_err().contains("outside the write roots")); + assert!(roots.check(s(base.join("in/x.png")), Access::Write).is_err()); + assert!(roots.check(s(base.join("scratch/x.png")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("out")), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn several_roots_compare_whole_folder_names() { + let (base, roots) = several("several-prefix"); + assert!(roots.check(s(base.join("in2/c.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"), "in2 is not inside in"); + assert!(roots.check(format!("{}2/x.designcraft", s(base.join("shared"))), Access::Read).is_err()); + assert!( + roots.check(s(base.join("scratch2/x.png")), Access::Write).unwrap_err().contains("outside the write roots"), + "scratch2 is not inside scratch" + ); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_between_several_roots() { + let (base, roots) = several("several-dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = |root: &str, rest: &str| format!("{}{sep}..{sep}{rest}", s(base.join(root))); + // `..` out of one root is outside, also when it lands next to another root. + assert!(roots.check(up("shared", &format!("outside{sep}secret.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(up("shared", "parent.designcraft"), Access::Read).is_err()); + assert!(roots.check(up("scratch", &format!("outside{sep}x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + // `..` from one root into another of the same kind stays inside. + assert_eq!(roots.check(up("shared", &format!("in{sep}a.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(up("scratch", &format!("out{sep}x.png")), Access::Write), Ok(())); + // ...but not into a root of the other kind. + assert!(roots.check(up("scratch", &format!("in{sep}x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + assert!(roots.check(up("shared", &format!("out{sep}x.png")), Access::Read).unwrap_err().contains("outside the read roots")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn several_roots_are_each_checked_and_counted_once() { + let (base, _) = folders("several-open"); + // A missing folder among several is an error, wherever it stands. + for read in [[base.join("in"), base.join("nope")], [base.join("nope"), base.join("in")]] { + let e = AutomationRoots::new(&[&read[0], &read[1]], &[]).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root") && e.contains("nope"), "{e}"); + } + let e = AutomationRoots::new(&[&base.join("in")], &[&base.join("out"), &base.join("in/a.designcraft")]).unwrap_err(); + assert!(e.starts_with("cannot open the automation write root"), "{e}"); + // A folder given twice (here once through `..`) counts once. + let twice = AutomationRoots::new(&[&base.join("in"), &base.join("in/sub/.."), &base.join("in")], &[]).unwrap().unwrap(); + let real = std::fs::canonicalize(&base).unwrap(); + assert_eq!(twice.describe(), format!("read {}; write none", s(real.join("in")))); + let e = twice.check(s(base.join("outside/x")), Access::Read).unwrap_err(); + assert!(e.contains("outside the read root ") && !e.contains("roots"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + /// A link inside one root that leads into another root of the same kind stays inside; one that + /// leads out of all of them, or into a root of the other kind, is outside. + #[cfg(unix)] + #[test] + fn links_between_several_roots() { + let (base, roots) = several("several-links"); + let link = |target: &str, at: &str| std::os::unix::fs::symlink(base.join(target), base.join(at)).unwrap(); + link("shared/b.designcraft", "in/to-shared.designcraft"); + link("outside/secret.designcraft", "shared/leak.designcraft"); + link("", "shared/up"); + link("out", "in/to-out"); + link("outside", "scratch/away"); + link("in", "scratch/to-in"); + assert_eq!(roots.check(s(base.join("in/to-shared.designcraft")), Access::Read), Ok(())); + assert!(roots.check(s(base.join("shared/leak.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("shared/up/outside/secret.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("shared/up/parent.designcraft")), Access::Read).is_err()); + assert!(roots.check(s(base.join("in/to-out/x.png")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("scratch/away/x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + assert!(roots.check(s(base.join("scratch/to-in/x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + let _ = std::fs::remove_dir_all(base); + } + #[cfg(windows)] #[test] fn windows_ignores_case_and_refuses_devices() { diff --git a/crates/engine/src/tests_roots.rs b/crates/engine/src/tests_roots.rs index 39ab5e57d..0f9b9654e 100644 --- a/crates/engine/src/tests_roots.rs +++ b/crates/engine/src/tests_roots.rs @@ -23,7 +23,7 @@ fn folders(name: &str) -> (PathBuf, Arc) { 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(); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap(); (base, roots) } diff --git a/crates/mcp/tests/it/automation_roots.rs b/crates/mcp/tests/it/automation_roots.rs index 871945920..8dcc3228d 100644 --- a/crates/mcp/tests/it/automation_roots.rs +++ b/crates/mcp/tests/it/automation_roots.rs @@ -51,7 +51,7 @@ fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) { 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 roots = AutomationRoots::new(read.then_some(in_dir.as_path()).as_slice(), write.then_some(out_dir.as_path()).as_slice()).unwrap(); let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots))); ok(&mut server, "new_document", json!({})); (server, base) @@ -339,7 +339,7 @@ fn without_roots_nothing_is_confined() { 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()))); + let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(&[], &[]).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"))})); diff --git a/docs/mcp.md b/docs/mcp.md index 7cf8227da..432a31794 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -88,6 +88,8 @@ claude mcp add designcraft -- designcraft-cli mcp ```sh designcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +designcraft-cli mcp --automation-read-root /work/project --automation-read-root /work/shared-assets \ + --automation-write-root /work/project/out --automation-write-root /tmp/scratch ``` ```json @@ -98,16 +100,16 @@ designcraft-cli mcp --automation-read-root /work/project --automation-write-root 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`, +- Every file a command reads must lie inside a **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 + Those are read through the same checks, so a path in a document that leads outside the read roots reads as a file that isn't there. -- Every file a command writes must lie inside the **write root**: `save_document`, `export_png`, +- Every file a command writes must lie inside a **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 @@ -115,21 +117,26 @@ config works across the suite) keep the agent to the folders you give it: `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. +- Each flag may be given more than once (#1552): a path inside any of the read roots may be read, + one inside any of the write roots written, so one session can read a project folder and a shared + assets folder, or write to an output folder and a scratch folder, without widening a root to + their common parent (which the roots don't grant). A folder given twice counts once. +- Read and write access are separate. Give the same folder to both flags for both. A kind 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, + file (or folder) link that leads out of the roots is outside them. 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 + create its target). The result is compared with each 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 path rejected: outside the write root /work/project/out: /etc/x.designcraft` (with + several, `outside the write roots /work/project/out, /tmp/scratch: ...`) 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 roots must be existing folders; a missing one stops the server with an error. Each flag takes + one folder per use (`--flag ` or `--flag=`) and may be used again for more. 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 From 7c3e8a9dc7ef54681f72504337c484ab36c36dd0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:24:59 +0000 Subject: [PATCH 08/71] Detect smooth anchors on all open subpaths in from_bezpath Found while testing crates/geom/src/path.rs:284-288 (MoveTo branch of PathData::from_bezpath) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/geom/src/path.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index b50234a8f..adfc4a2e4 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -281,9 +281,12 @@ impl PathData { for el in bp.elements() { match *el { PathEl::MoveTo(p) => { - if let Some(sp) = cur.take() + if let Some(mut sp) = cur.take() && !sp.anchors.is_empty() { + for a in &mut sp.anchors { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } subpaths.push(sp); } cur = Some(SubPath { anchors: vec![Anchor::corner(p)], closed: false }); @@ -418,6 +421,20 @@ mod tests { PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true)) } + #[test] + fn from_bezpath_detects_smooth_on_every_open_subpath() { + let mut bp = BezPath::new(); + for _ in 0..2 { + bp.move_to((0.0, 0.0)); + bp.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0)); + bp.curve_to((10.0, -10.0), (20.0, -10.0), (20.0, 0.0)); + } + let pd = PathData::from_bezpath(&bp); + assert_eq!(pd.subpaths.len(), 2); + assert_eq!(pd.subpaths[1].anchors[1].kind, AnchorKind::Smooth); + assert_eq!(pd.subpaths[0].anchors[1].kind, pd.subpaths[1].anchors[1].kind); + } + #[test] fn anchors_serialize_compactly_and_read_v1_maps() { let corner = Anchor::corner(Point::new(1.0, 2.0)); From d41f80537af3738b778c630f2658ad582d164f92 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:40:35 +0000 Subject: [PATCH 09/71] Join underline and strikethrough into one text-decoration in EPUB CSS Found while testing crates/epub/src/lib.rs:453-458 (char_css) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/epub/src/lib.rs | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 1df759608..40f782ff8 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -450,11 +450,12 @@ fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap {} } - if c.underline { - out.push_str("text-decoration: underline; "); - } - if c.strikethrough { - out.push_str("text-decoration: line-through; "); + // One declaration: a later `text-decoration` would replace the earlier one. + match (c.underline, c.strikethrough) { + (true, true) => out.push_str("text-decoration: underline line-through; "), + (true, false) => out.push_str("text-decoration: underline; "), + (false, true) => out.push_str("text-decoration: line-through; "), + (false, false) => {} } if let Some(h) = hex(doc, &c.fill, c.fill_tint) { let _ = write!(out, "color: {h}; "); @@ -2223,4 +2224,21 @@ mod tests { let last = "
  • 1 End note
  • "; assert_eq!(link_across_chapters(last, "content2.xhtml", &files), "
  • 1 End note
  • "); } + + #[test] + fn char_css_joins_underline_and_strikethrough_in_one_declaration() { + let d = Document::new(&NewDocument::default()); + let css = |u: bool, s: bool| { + let c = CharProps { underline: u, strikethrough: s, ..CharProps::default() }; + let mut out = String::new(); + char_css(&d, &c, 12.0, &BTreeMap::new(), &mut out); + out + }; + let both = css(true, true); + assert_eq!(both.matches("text-decoration:").count(), 1, "{both}"); + assert!(both.contains("text-decoration: underline line-through;"), "{both}"); + assert!(css(true, false).contains("text-decoration: underline;")); + assert!(css(false, true).contains("text-decoration: line-through;")); + assert!(!css(false, false).contains("text-decoration")); + } } From 4867a3f41bb7606ba11020cf4f032d99bbd69740 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:08:22 +0200 Subject: [PATCH 10/71] Saturate table style altRows counts instead of truncating to 32 bits Found while testing crates/engine/src/cmd/table.rs:1742 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/table.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 063be89ac..d94c3275a 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1751,7 +1751,7 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: O ts.borders = Default::default(); } if let Some(a) = p.get("altRows").and_then(Value::as_object) { - let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32); + let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| u32::try_from(v).unwrap_or(u32::MAX)); let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string(); let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32); ts.alt_rows = Some(designcraft_doc::AltFills { @@ -2466,3 +2466,22 @@ mod resize_border_tests { assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err()); } } + +#[cfg(test)] +mod style_alt_rows_tests { + use serde_json::json; + + use super::table_style_fields; + + #[test] + fn style_alt_rows_counts_saturate() { + let mut ts = designcraft_doc::TableStyle::default(); + let p = json!({"altRows": {"first": 4294967297u64, "next": 4294967296u64, "skipFirst": 4294967295u64, "skipLast": 3}}); + table_style_fields(&mut ts, &p, None); + let a = ts.alt_rows.unwrap(); + assert_eq!(a.first, u32::MAX, "first"); + assert_eq!(a.next, u32::MAX, "next"); + assert_eq!(a.skip_first, u32::MAX, "skip_first"); + assert_eq!(a.skip_last, 3, "skip_last"); + } +} From 38d692089fb3b494737d0a7dca7c2cec66b23c94 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:46:12 -0600 Subject: [PATCH 11/71] PDF: preserve placed optional-content visibility in vector exports Keep source defaults and per-frame hidden layers in plain and PDF/X-4 vector exports, including independent repeated placements, booklet output and book merging. Accept usage-application arrays and resource-only groups without a catalog configuration. Use file-named warnings and all-ON defaults for recoverable default-configuration problems and unsupported base states. Add readable groups omitted from OCGs without discarding their visibility, combine concurrent recovery warnings, and generate missing names only when no frame override needs name matching. Retain errors for contradictory states, unresolved references and ambiguous name overrides. Cache prepared variants per asset and hidden-name set while replaying recovery warnings through the existing deduplicating channel. Keep original source bytes when every group is visible and named, so reader-repaired cross-reference tables remain usable without an incremental layer update. When hidden groups or generated names require writing, reject an unusable cross-reference table with a file-named damage error and retain the first placement failure. Cover visibility, recovery, names, independent placements, repaired cross-reference tables and vector output with 46 optional-content integration tests, plus preparation-cache and shared-buffer unit tests. --- ROADMAP.md | 4 +- crates/engine/src/cmd/export.rs | 3 +- crates/engine/src/cmd/pdflayers.rs | 8 +- crates/engine/tests/pdf_optional_content.rs | 238 +++++++++++++++ .../engine/tests/support/optional_content.rs | 173 +++++++++++ .../support/optional_content_fallback.rs | 263 +++++++++++++++++ .../tests/support/optional_content_xref.rs | 143 +++++++++ crates/pdf/src/export.rs | 134 ++++++++- crates/pdf/src/lib.rs | 1 + crates/pdf/src/optional_content.rs | 279 ++++++++++++++++++ crates/pdf/src/registration_tests.rs | 204 +++++++++++++ crates/pdf/src/text.rs | 2 +- docs/target-app-parity.md | 3 +- 13 files changed, 1433 insertions(+), 22 deletions(-) create mode 100644 crates/engine/tests/pdf_optional_content.rs create mode 100644 crates/engine/tests/support/optional_content.rs create mode 100644 crates/engine/tests/support/optional_content_fallback.rs create mode 100644 crates/engine/tests/support/optional_content_xref.rs create mode 100644 crates/pdf/src/optional_content.rs create mode 100644 crates/pdf/src/registration_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 153555913..38397898c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,7 +132,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. - Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. -- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. +- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; plain and PDF/X-4 exports preserve their visible layers as vectors. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). - Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width. - View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups). @@ -387,6 +387,8 @@ Newest first. - **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change. - **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change. +- **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. +- **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change. - **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. - **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. - **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 551dd7484..52886c33c 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -733,9 +733,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { ), }; let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi)); - // Placed PDFs with hidden layers go out as images showing just their visible layers. let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); - let (layered, layer_count) = rasterize_layered(base)?; + let (layered, layer_count) = if opts.standard == Standard::PdfA2b { rasterize_layered(base)? } else { (None, 0) }; let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache); let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index 937fe81c0..c2543c3f2 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -374,8 +374,12 @@ pub(crate) mod tests { assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "the red layer is hidden: {right:?}"); assert!(px(&s, 125)[2] > 200, "blue still shows"); assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); - // PDF export carries the choice (the graphic goes out as an image). let r = s.execute("file.exportPdf", &json!({})).unwrap(); - assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("layer")), "{r}"); + assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}"); + let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + let image = designcraft_render::render_pdf_page(&bytes, 0, 792).unwrap(); + let width = usize::from(image.width()); + assert!(image.data()[150 * width + 125].a > 200); + assert_eq!(image.data()[150 * width + 175].a, 0); } } diff --git a/crates/engine/tests/pdf_optional_content.rs b/crates/engine/tests/pdf_optional_content.rs new file mode 100644 index 000000000..e96e34915 --- /dev/null +++ b/crates/engine/tests/pdf_optional_content.rs @@ -0,0 +1,238 @@ +#[path = "support/optional_content.rs"] +mod support; + +#[path = "support/optional_content_fallback.rs"] +mod fallback; + +#[path = "support/optional_content_xref.rs"] +mod xref; + +use designcraft_compose::Cache; +use designcraft_pdf::{PdfOptions, Standard, export_pdf, export_pdf_with_report}; +use support::{catalog, document, fixture, painted}; + +const DEFAULT: &str = "/BaseState/ON/OFF[6 3 R]"; +const USAGE_APPLICATIONS: &str = "/BaseState/ON/ON[5 0 R]/OFF[6 3 R]/AS[<><><>]"; + +#[test] +fn source_layer_dictionaries_are_readable() { + let pdf = hayro_syntax::Pdf::new(fixture(DEFAULT, "Hidden")).unwrap(); + for id in [(5, 0), (6, 3)] { + assert!(pdf.xref().get::>(hayro_syntax::object::ObjectIdentifier::new(id.0, id.1)).is_some(), "{id:?}"); + } +} + +fn exported(config: &str, standard: Standard) -> Vec { + let doc = document(fixture(config, "Hidden"), &[&[]]); + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, created: Some(0), ..Default::default() }).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + report.bytes +} + +#[test] +fn source_default_off_renders_before_export() { + let bytes = fixture(DEFAULT, "Hidden"); + + painted(&bytes, &[true, false]); +} + +#[test] +fn plain_export_registers_default_off_layers() { + let bytes = exported(DEFAULT, Standard::None); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); +} + +#[test] +fn pdfx_export_preserves_default_off_catalog() { + let bytes = exported(DEFAULT, Standard::PdfX4); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); +} + +#[test] +fn plain_export_paints_only_the_shown_layer() { + let bytes = exported(DEFAULT, Standard::None); + + painted(&bytes, &[true, false]); +} + +#[test] +fn pdfx_export_paints_only_the_shown_layer() { + let bytes = exported(DEFAULT, Standard::PdfX4); + + painted(&bytes, &[true, false]); +} + +#[test] +fn plain_export_preserves_defaults_with_usage_applications() { + let bytes = exported(USAGE_APPLICATIONS, Standard::None); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert_vector(&bytes); + painted(&bytes, &[true, false]); +} + +#[test] +fn pdfx_export_preserves_defaults_with_usage_applications() { + let bytes = exported(USAGE_APPLICATIONS, Standard::PdfX4); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + assert_vector(&bytes); + painted(&bytes, &[true, false]); +} + +#[test] +fn plain_export_draws_groups_without_catalog_properties() { + let doc = document(support::orphaned_fixture(), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + assert_vector(&bytes); + painted(&bytes, &[true, true]); +} + +#[test] +fn pdfx_export_draws_groups_without_catalog_properties() { + let doc = document(support::orphaned_fixture(), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + assert_vector(&bytes); + painted(&bytes, &[true, true]); +} + +#[test] +fn base_off_with_on_exception_is_preserved() { + let bytes = exported("/BaseState/OFF/ON[5 0 R]", Standard::None); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); +} + +#[test] +fn same_asset_placements_keep_opposing_layer_states() { + let doc = document(fixture(DEFAULT, "Hidden"), &[&[], &["Shown"]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Hidden".into(), true), ("Shown".into(), false), ("Shown".into(), true)]); + painted(&bytes, &[true, false, false, true]); +} + +#[test] +fn duplicate_names_with_distinct_generations_keep_default_states() { + let doc = document(fixture(DEFAULT, "Shown"), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Shown".into(), false), ("Shown".into(), true)]); + painted(&bytes, &[true, false]); +} + +#[test] +fn unresolved_default_off_reference_fails_export() { + let doc = document(fixture("/OFF[99 0 R]", "Hidden"), &[&[]]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions::default()); + + assert!(result.is_err(), "an unreadable layer state must not print hidden artwork"); +} + +#[test] +fn unlayered_pdf_stays_vector_without_optional_content() { + let doc = document(support::unlayered_fixture(), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let root = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + assert!(!root.contains_key(b"OCProperties")); + assert_vector(&bytes); + painted(&bytes, &[true, false]); +} + +fn assert_vector(bytes: &[u8]) { + use hayro_syntax::object::{Name, Object}; + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + for object in pdf.objects() { + if let Object::Stream(stream) = object { + assert_ne!(stream.dict().get::>(b"Subtype").as_ref().map(AsRef::as_ref), Some(b"Image".as_slice())); + } + } +} + +#[test] +fn layer_options_command_preserves_vector_overrides_in_plain_and_pdfx() { + use designcraft_engine::{ + Session, + cmd::{base64_decode, base64_encode}, + }; + use serde_json::json; + for standard in ["none", "x4"] { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session + .execute("file.place", &json!({"base64": base64_encode(&fixture(DEFAULT, "Hidden")), "name": "layers.pdf", "x": 0, "y": 0, "width": 100})) + .unwrap(); + session.execute("object.layerOptions", &json!({"hidden": ["Shown"]})).unwrap(); + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let bytes = base64_decode(result["base64"].as_str().unwrap()); + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), true)]); + assert_vector(&bytes); + painted(&bytes, &[false, true]); + } +} + +#[test] +fn exported_optional_content_survives_replacement_and_book_merge() { + let first = exported(DEFAULT, Standard::None); + let doc = document(first.clone(), &[&[]]); + + let replaced = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + let merged = designcraft_pdf::merge_pdfs(&[(first, vec![(100.0, 100.0)])], None).unwrap(); + + for bytes in [replaced, merged] { + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); + } +} + +#[test] +fn booklet_export_preserves_optional_content() { + let doc = document(fixture(DEFAULT, "Hidden"), &[&[]]); + let options = designcraft_pdf::BookletOptions { kind: designcraft_pdf::BookletKind::TwoUpConsecutive, ..Default::default() }; + + let report = designcraft_pdf::export_booklet(&doc, &Cache::new(), &options).unwrap(); + + assert!(report.warnings.is_empty()); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert_vector(&report.bytes); + painted(&report.bytes, &[true, false, false, false]); +} + +#[test] +fn archival_export_still_rejects_unvalidated_placed_pdf() { + let doc = document(fixture(DEFAULT, "Hidden"), &[&[]]); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, ..Default::default() }).unwrap_err(); + + assert!(error.to_string().contains("EmbeddedPDF"), "{error}"); +} + +#[test] +fn source_xref_stream_preserves_layer_states() { + let doc = document(support::stream_fixture(DEFAULT), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); +} diff --git a/crates/engine/tests/support/optional_content.rs b/crates/engine/tests/support/optional_content.rs new file mode 100644 index 000000000..3ed73d74f --- /dev/null +++ b/crates/engine/tests/support/optional_content.rs @@ -0,0 +1,173 @@ +use std::sync::Arc; + +use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef, build::NewDocument}; +use designcraft_geom::{Affine, Rect, shapes}; +use hayro_syntax::{ + Pdf, + object::{Array, Dict, Name, ObjectIdentifier}, +}; + +pub fn fixture(config: &str, second_name: &str) -> Vec { + layered_fixture(Some(config), second_name) +} + +pub fn orphaned_fixture() -> Vec { + layered_fixture(None, "Hidden") +} + +fn layered_fixture(config: Option<&str>, second_name: &str) -> Vec { + let properties = config.map(|config| format!("/OCProperties<>>>")).unwrap_or_default(); + properties_fixture(&properties, second_name) +} + +pub fn properties_fixture(properties: &str, second_name: &str) -> Vec { + grouped_fixture(properties, Some(second_name)) +} + +pub fn nameless_fixture(config: &str) -> Vec { + grouped_fixture(&format!("/OCProperties<>>>"), None) +} + +fn grouped_fixture(properties: &str, second_name: Option<&str>) -> Vec { + let content = "/OC /a BDC 1 0 0 0 k 0 0 50 100 re f EMC /OC /b BDC 0 1 0 0 k 50 0 50 100 re f EMC"; + let name = second_name.map(|name| format!("/Name({name})")).unwrap_or_default(); + let objects = [ + format!("<>"), + "<>".into(), + "<>>>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>>>/Custom(keep \\(this\\) >>)>>".into(), + format!("<>>>/Custom<00ff80>>>"), + ]; + assemble(&objects, &[2, 0, 0, 0, 0, 3]) +} + +pub fn unlayered_fixture() -> Vec { + let content = "1 0 0 0 k 0 0 50 100 re f"; + assemble( + &[ + "<>".into(), + "<>".into(), + "<>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ], + &[2, 0, 0, 0], + ) +} + +pub fn stream_fixture(config: &str) -> Vec { + let mut bytes = fixture(config, "Hidden"); + let tail = std::str::from_utf8(&bytes).unwrap(); + let previous: usize = tail.rsplit("startxref").next().unwrap().split_whitespace().next().unwrap().parse().unwrap(); + let offset = bytes.len(); + let mut entry = vec![1]; + entry.extend_from_slice(&u32::try_from(offset).unwrap().to_be_bytes()); + entry.extend_from_slice(&0u16.to_be_bytes()); + bytes.extend_from_slice(format!("7 0 obj\n<>\nstream\n").as_bytes()); + bytes.extend_from_slice(&entry); + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); + bytes +} + +fn assemble(objects: &[String], generations: &[i32]) -> Vec { + let mut bytes = b"%PDF-1.5\n".to_vec(); + let mut offsets = Vec::new(); + for (index, body) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} {} obj\n{body}\nendobj\n", index + 1, generations[index]).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for (offset, generation) in offsets.iter().zip(generations) { + bytes.extend_from_slice(format!("{offset:010} {generation:05} n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +pub fn document(bytes: Vec, hidden_sets: &[&[&str]]) -> Document { + let width = 100.0 * f64::from(u32::try_from(hidden_sets.len()).unwrap()); + let mut doc = Document::new(&NewDocument { width, height: 100.0, facing_pages: false, ..Default::default() }); + let asset = AssetId(doc.alloc()); + doc.add_asset(Asset { + id: asset, + name: "layers.pdf".into(), + mime: "application/pdf".into(), + data: Arc::new(bytes), + link: None, + pixels: None, + page: 0, + pdf_crop: Default::default(), + pdf_box: None, + }); + for (index, hidden) in hidden_sets.iter().enumerate() { + let x = 100.0 * f64::from(u32::try_from(index).unwrap()); + let mut item = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + item.xf = Affine::translate((x, 0.0)); + item.pdf_hidden_layers = hidden.iter().map(|name| (*name).to_owned()).collect(); + item.content = Content::Graphic(Graphic { + asset, + size: (100.0, 100.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + } + doc +} + +pub fn refs(dict: &Dict<'_>, key: &[u8]) -> Vec { + dict.get::>(key).unwrap().raw_iter().map(|value| value.as_obj_ref().unwrap().into()).collect() +} + +pub fn catalog(bytes: &[u8]) -> Vec<(String, bool)> { + let pdf = Pdf::new(bytes.to_vec()).unwrap(); + let root = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + let properties = root.get::>(b"OCProperties").expect("export must register its embedded OCGs"); + let groups = refs(&properties, b"OCGs"); + let defaults = properties.get::>(b"D").unwrap(); + assert!(defaults.get::>(b"Name").is_some()); + assert_eq!(defaults.get::>(b"BaseState").unwrap().as_ref(), b"ON"); + assert!(!defaults.contains_key(b"AS")); + assert_eq!(refs(&defaults, b"Order"), groups); + let off = refs(&defaults, b"OFF"); + assert!(off.iter().all(|id| groups.contains(id))); + let mut unique = groups.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), groups.len()); + let mut result = Vec::new(); + for id in groups { + let group = pdf.xref().get::>(id).unwrap(); + assert!(group.contains_key(b"Usage")); + assert!(group.contains_key(b"Intent")); + assert!(group.contains_key(b"Custom")); + let custom = group.get::>(b"Custom").unwrap(); + assert!([b"keep (this) >>".as_slice(), &[0, 255, 128]].contains(&custom.as_bytes())); + let intent = group.get::>(b"Intent").unwrap(); + assert_eq!(intent.iter::>().map(|name| name.as_ref().to_vec()).collect::>(), [b"View".to_vec(), b"Design".to_vec()]); + let usage = group.get::>(b"Usage").unwrap().get::>(b"Print").unwrap(); + assert_eq!(usage.get::>(b"PrintState").unwrap().as_ref(), b"ON"); + let name = group.get::>(b"Name").unwrap(); + result.push((String::from_utf8(name.as_bytes().to_vec()).unwrap(), off.contains(&id))); + } + result.sort(); + result +} + +pub fn painted(bytes: &[u8], expected: &[bool]) { + let width = u32::try_from(expected.len()).unwrap() * 50; + let image = designcraft_render::render_pdf_page(bytes, 0, width.max(100)).unwrap(); + for (index, painted) in expected.iter().enumerate() { + let image_width = usize::from(image.width()); + let x = (2 * index + 1) * image_width / (2 * expected.len()); + let y = usize::from(image.height()) / 2; + let pixel = image.data()[y * image_width + x]; + assert_eq!(pixel.a > 200, *painted, "half {index}: {pixel:?}"); + } +} diff --git a/crates/engine/tests/support/optional_content_fallback.rs b/crates/engine/tests/support/optional_content_fallback.rs new file mode 100644 index 000000000..5a94bb7b1 --- /dev/null +++ b/crates/engine/tests/support/optional_content_fallback.rs @@ -0,0 +1,263 @@ +use super::support::{catalog, document, nameless_fixture, painted, properties_fixture}; +use designcraft_compose::Cache; +use designcraft_engine::{Session, cmd::base64_encode}; +use designcraft_pdf::{PdfOptions, Standard, export_pdf, export_pdf_with_report}; +use serde_json::json; + +const CASES: &[&str] = &[ + "/OCGs[5 0 R 6 3 R]", + "/OCGs[5 0 R 6 3 R]/D null", + "/OCGs[5 0 R 6 3 R]/D 42", + "/OCGs[5 0 R 6 3 R]/D 99 0 R", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", +]; + +fn incomplete_inventory(standard: &str, off: bool) { + let cases: &[&str] = if off { + &["/OCGs[5 0 R]/D<>", "/D<>"] + } else { + &["/OCGs[6 3 R]/D<>"] + }; + for properties in cases { + let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"); + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session.execute("file.place", &json!({"base64": base64_encode(&source), "name": "incomplete.pdf", "x": 0, "y": 0, "width": 100})).unwrap(); + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap()); + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); + super::assert_vector(&bytes); + let warnings = result["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1); + let warning = warnings[0].as_str().unwrap(); + assert!(warning.contains("incomplete.pdf") && warning.contains("layer list incomplete"), "{warning}"); + assert!(!warning.contains("layer state not readable"), "{warning}"); + if standard == "x4" { + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + } + } +} + +#[test] +fn plain_incomplete_inventory_preserves_off() { + incomplete_inventory("none", true); +} + +#[test] +fn pdfx_incomplete_inventory_preserves_off() { + incomplete_inventory("x4", true); +} + +#[test] +fn plain_incomplete_inventory_preserves_on() { + incomplete_inventory("none", false); +} + +#[test] +fn pdfx_incomplete_inventory_preserves_on() { + incomplete_inventory("x4", false); +} + +#[test] +fn incomplete_inventory_reports_both_warnings_during_fallback() { + for standard in [Standard::None, Standard::PdfX4] { + let source = properties_fixture("/OCProperties<>>>", "Hidden"); + let doc = document(source, &[&[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + + assert_eq!(report.warnings.len(), 1); + let warning = &report.warnings[0]; + assert!( + warning.contains("layers.pdf") && warning.contains("layer list incomplete") && warning.contains("layer state not readable"), + "{warning}" + ); + painted(&report.bytes, &[true, true]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } + } +} + +fn visible_recovery(source: Vec, standard: &str) { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session.execute("file.place", &json!({"base64": base64_encode(&source), "name": "visible-recovery.pdf", "x": 0, "y": 0, "width": 100})).unwrap(); + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let warnings = result["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].as_str().unwrap().contains("visible-recovery.pdf")); + let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap()); + let groups = catalog(&bytes); + assert_eq!(groups.len(), 2); + assert!(groups.iter().all(|(name, hidden)| !name.is_empty() && !hidden)); + super::assert_vector(&bytes); + painted(&bytes, &[true, true]); + if standard == "x4" { + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + } +} + +#[test] +fn plain_export_recovers_malformed_on_when_defaults_are_visible() { + visible_recovery(super::support::fixture("/BaseState/ON/ON 42", "Hidden"), "none"); +} + +#[test] +fn pdfx_export_recovers_malformed_on_when_defaults_are_visible() { + visible_recovery(super::support::fixture("/BaseState/ON/ON 42", "Hidden"), "x4"); +} + +#[test] +fn plain_export_names_visible_group_when_display_name_is_missing() { + visible_recovery(nameless_fixture("/BaseState/ON"), "none"); +} + +#[test] +fn pdfx_export_names_visible_group_when_display_name_is_missing() { + visible_recovery(nameless_fixture("/BaseState/ON"), "x4"); +} + +#[test] +fn missing_display_name_fails_when_an_override_could_match() { + for standard in [Standard::None, Standard::PdfX4] { + let doc = document(nameless_fixture("/BaseState/ON"), &[&["Shown"]]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }); + + assert!(result.is_err(), "a nameless group could match the explicit hidden name"); + } +} + +#[test] +fn malformed_on_fails_when_base_state_can_hide_groups() { + for standard in [Standard::None, Standard::PdfX4] { + let doc = document(super::support::fixture("/BaseState/OFF/ON 42", "Hidden"), &[&[]]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }); + + assert!(result.is_err(), "all-ON recovery would expose groups hidden by BaseState OFF"); + } +} + +#[test] +fn name_repair_preserves_readable_default_off_state() { + for standard in [Standard::None, Standard::PdfX4] { + let doc = document(nameless_fixture("/BaseState/ON/OFF[6 3 R]"), &[&[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + + assert_eq!(report.warnings.len(), 1); + assert!(report.warnings[0].contains("layers.pdf")); + assert_eq!(catalog(&report.bytes).iter().filter(|(_, hidden)| *hidden).count(), 1); + painted(&report.bytes, &[true, false]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } + } +} + +fn fallback_exports(standard: Standard) { + let mut failures = Vec::new(); + for properties in CASES { + let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"); + let doc = document(source, &[&[], &["Shown"]]); + + let report = match export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, created: Some(0), ..Default::default() }) { + Ok(report) => report, + Err(error) => { + failures.push(format!("{properties}: {error}")); + continue; + } + }; + + if report.warnings.len() != 1 { + failures.push(format!("{properties}: expected one fallback warning, got {:?}", report.warnings)); + continue; + } + assert!(report.warnings[0].contains("layers.pdf")); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Hidden".into(), false), ("Shown".into(), false), ("Shown".into(), true)]); + super::assert_vector(&report.bytes); + painted(&report.bytes, &[true, true, false, true]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } + } + assert!(failures.is_empty(), "fallback export failures: {failures:#?}"); +} + +#[test] +fn plain_fallback_shows_all_layers_and_preserves_frame_overrides() { + fallback_exports(Standard::None); +} + +#[test] +fn pdfx_fallback_shows_all_layers_and_preserves_frame_overrides() { + fallback_exports(Standard::PdfX4); +} + +#[test] +fn file_export_reports_fallback_with_filename_and_preserves_overrides() { + for properties in CASES { + for standard in ["none", "x4"] { + for hidden in [false, true] { + let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"); + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session + .execute("file.place", &json!({"base64": base64_encode(&source), "name": "fallback.pdf", "x": 0, "y": 0, "width": 100})) + .unwrap(); + if hidden { + session.execute("object.layerOptions", &json!({"hidden": ["Shown"]})).unwrap(); + } + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let warnings = result["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1, "{properties}: {result}"); + assert!(warnings[0].as_str().unwrap().contains("fallback.pdf")); + let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap()); + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), hidden)]); + painted(&bytes, &[!hidden, true]); + } + } + } +} + +#[test] +fn contradictory_and_unresolved_states_fail_even_with_fallback_or_overrides() { + for properties in [ + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R 99 0 R]", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + ] { + for standard in [Standard::None, Standard::PdfX4] { + for hidden in [&[][..], &["Shown"][..]] { + let doc = document(properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"), &[hidden]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }); + + assert!(result.is_err(), "unsafe state accepted: {properties}"); + } + } + } +} diff --git a/crates/engine/tests/support/optional_content_xref.rs b/crates/engine/tests/support/optional_content_xref.rs new file mode 100644 index 000000000..9730969e2 --- /dev/null +++ b/crates/engine/tests/support/optional_content_xref.rs @@ -0,0 +1,143 @@ +use super::support::{catalog, document, fixture, nameless_fixture, painted}; +use designcraft_compose::Cache; +use designcraft_doc::{AssetId, Content}; +use designcraft_pdf::{BookletKind, BookletOptions, PdfOptions, Standard, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs}; +use std::sync::Arc; + +fn broken_xref(bytes: Vec) -> Vec { + let text = String::from_utf8(bytes).unwrap(); + let (body, _) = text.rsplit_once("startxref").unwrap(); + let bytes = format!("{body}startxref\n1\n%%EOF\n").into_bytes(); + let pdf = hayro_syntax::Pdf::new(bytes.clone()).expect("hayro must repair the synthetic broken xref"); + assert_eq!(pdf.pages().len(), 1); + let root = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + assert!(root.get::>(b"OCProperties").is_some()); + bytes +} + +fn all_visible(standard: Standard) { + let source = broken_xref(fixture("/BaseState/ON", "Hidden")); + painted(&source, &[true, true]); + let doc = document(source, &[&[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + super::assert_vector(&report.bytes); + painted(&report.bytes, &[true, true]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } +} + +#[test] +fn plain_export_accepts_repaired_xref_when_all_layers_are_visible() { + all_visible(Standard::None); +} + +#[test] +fn pdfx_export_accepts_repaired_xref_when_all_layers_are_visible() { + all_visible(Standard::PdfX4); +} + +#[test] +fn booklet_accepts_repaired_xref_when_all_layers_are_visible() { + let doc = document(broken_xref(fixture("/BaseState/ON", "Hidden")), &[&[]]); + let options = BookletOptions { kind: BookletKind::TwoUpConsecutive, ..Default::default() }; + + let report = export_booklet(&doc, &Cache::new(), &options).unwrap(); + + assert!(report.warnings.is_empty()); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + super::assert_vector(&report.bytes); + painted(&report.bytes, &[true, true, false, false]); +} + +#[test] +fn book_merge_accepts_repaired_xref_when_all_layers_are_visible() { + let source = broken_xref(fixture("/BaseState/ON", "Hidden")); + + let bytes = merge_pdfs(&[(source, vec![(100.0, 100.0)])], None).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + super::assert_vector(&bytes); + painted(&bytes, &[true, true]); +} + +#[test] +fn repaired_xref_with_default_off_fails_with_named_damage_error() { + let doc = document(broken_xref(fixture("/BaseState/ON/OFF[6 3 R]", "Hidden")), &[&[]]); + + for standard in [Standard::None, Standard::PdfX4] { + let error = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap_err().to_string(); + assert!(error.contains("layers.pdf"), "{error}"); + assert!(error.contains("cross-reference table is damaged"), "{error}"); + assert!(!error.contains("optional-content state"), "{error}"); + } +} + +#[test] +fn repaired_xref_with_explicit_hidden_layer_fails_with_named_damage_error() { + let doc = document(broken_xref(fixture("/BaseState/ON", "Hidden")), &[&["Hidden"]]); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert!(error.contains("layers.pdf") && error.contains("cross-reference table is damaged"), "{error}"); +} + +#[test] +fn repaired_xref_needing_generated_name_fails_with_named_damage_error() { + let doc = document(broken_xref(nameless_fixture("/BaseState/ON")), &[&[]]); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert!(error.contains("layers.pdf") && error.contains("cross-reference table is damaged"), "{error}"); +} + +fn two_assets(first: Vec, second: Vec) -> designcraft_doc::Document { + let mut doc = document(first, &[&[], &[]]); + let original = doc.assets.values().next().unwrap().as_ref().clone(); + Arc::make_mut(doc.assets.values_mut().next().unwrap()).name = "first.pdf".into(); + let id = AssetId(doc.alloc()); + let mut other = original; + other.id = id; + other.name = "second.pdf".into(); + other.data = Arc::new(second); + doc.add_asset(other); + let spread = Arc::make_mut(doc.spreads.first_mut().unwrap()); + let item = Arc::make_mut(spread.items.last_mut().unwrap()); + let Content::Graphic(graphic) = &mut item.content else { panic!("fixture must have a graphic") }; + graphic.asset = id; + doc +} + +#[test] +fn error_identifies_unresolvable_pdf_among_two_placements() { + let doc = two_assets(fixture("/BaseState/ON", "Hidden"), fixture("/OFF[99 0 R]", "Hidden")); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert_eq!(error, "PDF writer error: second.pdf: the placed PDF's optional-content state could not be preserved safely"); +} + +#[test] +fn first_placement_error_is_not_overwritten() { + let doc = two_assets(fixture("/OFF[99 0 R]", "Hidden"), fixture("/OFF[98 0 R]", "Hidden")); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert!(error.contains("first.pdf"), "{error}"); + assert!(!error.contains("second.pdf"), "{error}"); +} + +#[test] +fn repeated_recoverable_variant_reports_one_warning_and_paints_each_placement() { + let doc = document(fixture("/BaseState/Unchanged", "Hidden"), &[&[], &[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(report.warnings.len(), 1); + assert!(report.warnings[0].contains("layers.pdf")); + painted(&report.bytes, &[true, true, true, true]); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 34934b911..61fea9ac2 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1,5 +1,9 @@ //! Document → PDF. The item walk mirrors `designcraft-render`'s `draw_spread` / `draw_item`. +#[cfg(test)] +#[path = "registration_tests.rs"] +pub(crate) mod registration_tests; + use std::collections::HashMap; use std::sync::Arc; @@ -169,6 +173,11 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions #[cfg(test)] image_conversions: 0, pdfs: HashMap::new(), + variants: HashMap::new(), + has_optional_content: false, + #[cfg(test)] + pdf_preparations: 0, + layer_error: None, version, too_new: Vec::new(), svgs: HashMap::new(), @@ -227,6 +236,9 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions pdf.set_tag_tree(ex.tag_tree()); } ex.check_placed_versions()?; + if let Some(error) = ex.layer_error { + return Err(error); + } 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; @@ -235,6 +247,9 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions // group that got past it, with its page. crate::pdfx::cmyk_group_spaces(&mut bytes); } + if ex.has_optional_content { + bytes = crate::optional_content::register(&bytes)?; + } // 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. @@ -277,8 +292,11 @@ fn merge_pdfs_inner(parts: &[(Vec, Vec<(f32, f32)>)], title: Option<&str>) - let mut writer = Writer::new(krilla::SerializeSettings::default(), metadata); let pdf = writer.document_mut(); let mut pages = 0; + let mut has_optional_content = false; for (bytes, sizes) in parts { - let src = krilla::pdf::Pdf::new(Arc::new(bytes.clone())).map_err(|e| PdfError::Write(format!("{e:?}")))?; + let variant = crate::optional_content::prepare(&Arc::new(bytes.clone()), &[])?; + has_optional_content |= !sizes.is_empty() && variant.has_optional_content; + let src = krilla::pdf::Pdf::new(variant.data).map_err(|e| PdfError::Write(format!("{e:?}")))?; let doc = krilla::pdf::PdfDocument::new(Arc::new(src)); for (i, &(w, h)) in sizes.iter().enumerate() { let size = Size::from_wh(w.max(1.0), h.max(1.0)).ok_or(PdfError::NoPages)?; @@ -295,7 +313,8 @@ fn merge_pdfs_inner(parts: &[(Vec, Vec<(f32, f32)>)], title: Option<&str>) - if pages == 0 { return Err(PdfError::NoPages); } - writer.finish().map_err(PdfError::from) + let bytes = writer.finish().map_err(PdfError::from)?; + if has_optional_content { crate::optional_content::register(&bytes) } else { Ok(bytes) } } /// File › Print Booklet: how pages pair up on printer spreads. @@ -356,6 +375,11 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> #[cfg(test)] image_conversions: 0, pdfs: HashMap::new(), + variants: HashMap::new(), + has_optional_content: false, + #[cfg(test)] + pdf_preparations: 0, + layer_error: None, // krilla's default. version: PdfVersion::Pdf17, too_new: Vec::new(), @@ -405,10 +429,16 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> page.finish(); } ex.check_placed_versions()?; + if let Some(error) = ex.layer_error { + return Err(error); + } 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); } + if ex.has_optional_content { + bytes = crate::optional_content::register(&bytes)?; + } let mut warnings = ex.warnings; warnings.dedup(); Ok(ExportReport { bytes, pages: pairs.len(), warnings }) @@ -487,7 +517,12 @@ pub(crate) struct Exporter<'a> { #[cfg(test)] image_conversions: usize, /// Placed PDFs (embedded as vector pages) and their page counts. - pdfs: HashMap>, + pdfs: HashMap<(AssetId, Vec), Option<(krilla::pdf::PdfDocument, usize)>>, + variants: HashMap<(AssetId, Vec), crate::optional_content::Variant>, + has_optional_content: bool, + #[cfg(test)] + pdf_preparations: usize, + layer_error: Option, /// The PDF version written; placed PDFs must not be newer. version: PdfVersion, /// Placed PDFs too new to embed in this version ("name is PDF 2.0"): the export fails. @@ -965,7 +1000,7 @@ impl Exporter<'_> { let g = &if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() }; if let Some(p) = &path { s.push_clip_path(p, &FillRule::NonZero); - self.graphic(s, g); + self.graphic(s, g, &it.pdf_hidden_layers); s.pop(); } } @@ -1258,9 +1293,9 @@ impl Exporter<'_> { /// `qpdf --force-version=1.6` does). A newer /// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]). /// Returns the document with its page count. - fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option<(krilla::pdf::PdfDocument, usize)> { + fn placed_pdf(&mut self, asset: &designcraft_doc::Asset, data: Arc>) -> Option<(krilla::pdf::PdfDocument, usize)> { use hayro_syntax::PdfVersion as V; - let Ok(pdf) = krilla::pdf::Pdf::new(asset.data.clone()) else { + let Ok(pdf) = krilla::pdf::Pdf::new(data.clone()) else { self.warn(format!("{}: can't read the placed PDF", asset.name)); return None; }; @@ -1278,7 +1313,7 @@ impl Exporter<'_> { } if have == V::Pdf17 && max == V::Pdf16 - && let bytes = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| asset.data.to_vec()) + && let bytes = relabel_pdf_header(&data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| data.to_vec()) && let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(relabel_catalog_version(bytes))) && pdf.version() <= max { @@ -1372,24 +1407,24 @@ impl Exporter<'_> { } /// Draw a placed graphic, inside its clipping path when it has one. - pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) { + pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: &[String]) { match g.clip_region().map(|(region, even_odd)| (to_path(®ion), even_odd)) { Some((Some(clip), even_odd)) => { s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero }); - self.graphic_unclipped(s, g); + self.graphic_unclipped(s, g, hidden); s.pop(); } // A degenerate outline shows nothing, or everything when inverted. Some((None, even_odd)) => { if even_odd { - self.graphic_unclipped(s, g); + self.graphic_unclipped(s, g, hidden); } } - None => self.graphic_unclipped(s, g), + None => self.graphic_unclipped(s, g, hidden), } } - fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) { + fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: &[String]) { // Video / sound without a poster: a dark frame with a play mark. if let Some(asset) = self.doc.assets.get(&g.asset) && designcraft_doc::media_kind(&asset.mime).is_some() @@ -1423,11 +1458,39 @@ impl Exporter<'_> { let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t)); let Some(size) = Size::from_wh(page_w.max(1e-3) as f32, page_h.max(1e-3) as f32) else { return }; - let placed = match self.pdfs.get(&g.asset) { + let request = (g.asset, hidden.to_vec()); + let variant = match self.variants.get(&request) { + Some(variant) => variant.clone(), + None => { + #[cfg(test)] + { + self.pdf_preparations += 1; + } + match crate::optional_content::prepare(&asset.data, hidden) { + Ok(variant) => { + self.variants.insert(request, variant.clone()); + variant + } + Err(error) => { + let message = match error { + PdfError::Write(message) => message, + other => other.to_string(), + }; + self.layer_error.get_or_insert(PdfError::Write(format!("{}: {message}", asset.name))); + return; + } + } + } + }; + if let Some(warning) = variant.warning { + self.warn(format!("{}: {warning}", asset.name)); + } + let key = (g.asset, variant.hidden); + let placed = match self.pdfs.get(&key) { Some(p) => p.clone(), None => { - let p = self.placed_pdf(asset); - self.pdfs.insert(g.asset, p.clone()); + let p = self.placed_pdf(asset, variant.data); + self.pdfs.insert(key, p.clone()); p } }; @@ -1437,6 +1500,7 @@ impl Exporter<'_> { self.warn(format!("{}: the placed PDF has no page {}", asset.name, u64::from(asset.page) + 1)); } Some((placed, _)) => { + self.has_optional_content |= variant.has_optional_content; s.push_transform(&tf(g.xf)); let clip = shown.and_then(|_| to_path(&Rect::new(0.0, 0.0, g.size.0, g.size.1).to_path(0.1))); if let Some(clip) = &clip { @@ -1884,6 +1948,10 @@ mod image_content_cache_tests { images_by_content: ImageContentCache::default(), image_conversions: 0, pdfs: HashMap::new(), + variants: HashMap::new(), + has_optional_content: false, + pdf_preparations: 0, + layer_error: None, version: PdfVersion::Pdf17, too_new: Vec::new(), svgs: HashMap::new(), @@ -1898,6 +1966,42 @@ mod image_content_cache_tests { } } #[test] + fn prepared_pdf_variants_reuse_parsing_by_asset_and_hidden_names() { + let mut doc = Document::new(&Default::default()); + let cache = Cache::new(); + let opts = PdfOptions::default(); + let source = export_pdf(&doc, &cache, &opts).unwrap(); + let a = asset(&mut doc, source.clone(), "a.pdf"); + let b = asset(&mut doc, source, "b.pdf"); + let mut ex = exporter(&doc, &cache, &opts); + let mut writer = Writer::new(Default::default(), Default::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(100.0, 100.0).unwrap())); + let mut surface = page.surface(); + for (id, hidden, count) in [(a, vec![], 1), (a, vec![], 1), (a, vec!["Hidden".into()], 2), (b, vec![], 3), (a, vec![], 3)] { + let graphic = designcraft_doc::Graphic { + asset: id, + size: (100.0, 100.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }; + ex.graphic_unclipped(&mut surface, &graphic, &hidden); + assert!(ex.layer_error.is_none()); + assert_eq!(ex.pdf_preparations, count, "count actual preparations, not writer resource deduplication"); + } + assert_eq!(ex.variants.len(), 3); + assert_eq!(ex.pdfs.len(), 2); + let variant = ex.variants.get(&(a, Vec::new())).unwrap(); + assert!(Arc::ptr_eq(&variant.data, &doc.assets[&a].data)); + surface.finish(); + page.finish(); + assert!(writer.finish().is_ok()); + } + #[test] fn exact_bytes_reuse_conversion_under_real_hash_collisions() { use std::hash::{BuildHasher, Hasher}; let mut d = Document::new(&Default::default()); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ad1123897..72e4028cd 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -35,6 +35,7 @@ mod marks; mod text; mod forms; +mod optional_content; mod pdfx; mod transitions; pub use export::{BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs, sheet_spreads}; diff --git a/crates/pdf/src/optional_content.rs b/crates/pdf/src/optional_content.rs new file mode 100644 index 000000000..8bd795937 --- /dev/null +++ b/crates/pdf/src/optional_content.rs @@ -0,0 +1,279 @@ +use hayro_syntax::Pdf; +use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream}; +use hayro_syntax::reader::{Reader, ReaderExt}; +use std::sync::Arc; + +use crate::{PdfError, Result}; + +const MARKER: &[u8] = b"DesignCraftHiddenOCG"; + +fn invalid() -> PdfError { + PdfError::Write("the placed PDF's optional-content state could not be preserved safely".into()) +} + +fn references(dict: &Dict<'_>, key: &[u8]) -> Result> { + if !dict.contains_key(key) { + return Ok(Vec::new()); + } + dict.get::>(key).ok_or_else(invalid)?.raw_iter().map(|value| value.as_obj_ref().map(Into::into).ok_or_else(invalid)).collect() +} + +fn edited(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])]) -> Result> { + let data = dict.data(); + let body = data.strip_prefix(b"<<").and_then(|d| d.strip_suffix(b">>")).ok_or_else(invalid)?; + let mut reader = Reader::new(body); + let mut out = b"<<".to_vec(); + loop { + reader.skip_white_spaces_and_comments(); + if reader.at_end() { + break; + } + let start = reader.offset(); + let name = reader.read_without_context::>().ok_or_else(invalid)?; + reader.skip_white_spaces_and_comments(); + reader.skip::>>(false).ok_or_else(invalid)?; + if !replacements.iter().any(|(key, _)| *key == name.as_ref()) { + out.push(b'\n'); + out.extend_from_slice(body.get(start..reader.offset()).ok_or_else(invalid)?); + } + } + for (key, value) in replacements { + if !value.is_empty() { + out.extend_from_slice(b"\n/"); + out.extend_from_slice(key); + out.push(b' '); + out.extend_from_slice(value); + } + } + out.extend_from_slice(b"\n>>"); + Ok(out) +} + +fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec)], root: ObjectIdentifier) -> Result> { + let damaged = || PdfError::Write("the placed PDF's cross-reference table is damaged; layer changes could not be preserved safely".into()); + let at = crate::transitions::rfind(bytes, b"startxref").ok_or_else(damaged)?; + let tail = std::str::from_utf8(bytes.get(at..).ok_or_else(damaged)?).map_err(|_| damaged())?; + let prev = crate::transitions::int_after(tail, "startxref").ok_or_else(damaged)?; + let section = bytes.get(prev..).ok_or_else(damaged)?; + let trailer = if section.starts_with(b"xref") { + let at = crate::transitions::find(section, b"trailer", 0).ok_or_else(damaged)?; + let mut reader = Reader::new(section.get(at + 7..).ok_or_else(damaged)?); + reader.skip_white_spaces_and_comments(); + reader.read_without_context::>().ok_or_else(damaged)? + } else { + let mut reader = Reader::new(section); + reader.read_without_context::().ok_or_else(damaged)?; + reader.skip_white_spaces_and_comments(); + let stream = reader.read_without_context::>().ok_or_else(damaged)?; + if stream.dict().get::>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"XRef") { + return Err(damaged()); + } + stream.dict().clone() + }; + if trailer.contains_key(b"Encrypt") { + return Err(invalid()); + } + trailer.get::(b"Size").filter(|n| *n > 0).ok_or_else(damaged)?; + let mut out = bytes.to_vec(); + out.push(b'\n'); + let mut offsets = Vec::new(); + for (id, body) in changes { + offsets.push((*id, out.len())); + out.extend_from_slice(format!("{} {} obj\n", id.obj_number, id.gen_number).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(b"xref\n"); + offsets.sort_by_key(|(id, _)| *id); + for (id, offset) in offsets { + out.extend_from_slice(format!("{} 1\n{offset:010} {:05} n\r\n", id.obj_number, id.gen_number).as_bytes()); + } + let root = format!("{} {} R", root.obj_number, root.gen_number); + let prev = prev.to_string(); + let trailer = edited( + &trailer, + &[ + (b"Root", root.as_bytes()), + (b"Prev", prev.as_bytes()), + (b"XRefStm", b""), + (b"Type", b""), + (b"W", b""), + (b"Index", b""), + (b"Length", b""), + (b"Filter", b""), + (b"DecodeParms", b""), + ], + )?; + out.extend_from_slice(b"trailer\n"); + out.extend_from_slice(&trailer); + out.extend_from_slice(format!("\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Ok(out) +} + +#[derive(Clone)] +pub(crate) struct Variant { + pub data: Arc>, + pub hidden: Vec, + pub warning: Option, + pub has_optional_content: bool, +} + +pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result { + let pdf = Pdf::new(bytes.clone()).map_err(|_| invalid())?; + let xref = pdf.xref(); + let root = xref.get::>(xref.root_id()).ok_or_else(invalid)?; + if !root.contains_key(b"OCProperties") { + let has_optional_content = pdf + .objects() + .into_iter() + .any(|object| matches!(object, Object::Dict(dict) if dict.get::>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"OCG"))); + return Ok(Variant { data: bytes.clone(), hidden: Vec::new(), warning: None, has_optional_content }); + } + let properties = root.get::>(b"OCProperties").ok_or_else(invalid)?; + let mut groups = references(&properties, b"OCGs")?; + let config = properties.get::>(b"D"); + let mut fallback = config.is_none(); + let config = config.unwrap_or_default(); + let base_off = match config.get::>(b"BaseState").as_ref().map(AsRef::as_ref) { + None if !config.contains_key(b"BaseState") => false, + Some(b"ON") => false, + Some(b"OFF") => true, + None | Some(_) => { + fallback = true; + false + } + }; + let off = references(&config, b"OFF")?; + let on = match references(&config, b"ON") { + Ok(on) => on, + Err(error) if !fallback && !base_off && off.is_empty() => { + let value = config.get::>(b"ON").ok_or(error)?; + fallback = true; + value.into_array().map(|array| array.raw_iter().filter_map(|value| value.as_obj_ref().map(Into::into)).collect()).unwrap_or_default() + } + Err(error) => return Err(error), + }; + if on.iter().any(|id| off.contains(id)) { + return Err(invalid()); + } + let incomplete = on.iter().chain(&off).any(|id| !groups.contains(id)); + groups.extend(on.iter().chain(&off).copied()); + groups.sort(); + groups.dedup(); + // Aggregating applications use the default state, not /AS usage-event adjustments. + let mut changes = Vec::new(); + let mut effective = Vec::new(); + let mut renamed = false; + for id in groups { + let group = xref.get::>(id).ok_or_else(invalid)?; + if group.get::>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"OCG") { + return Err(invalid()); + } + let name = group.get::>(b"Name"); + let is_hidden = match (hidden.is_empty(), name.as_ref()) { + (true, _) => !fallback && (off.contains(&id) || (base_off && !on.contains(&id))), + (false, Some(name)) => { + let name = String::from_utf8_lossy(name.as_bytes()); + hidden.iter().any(|n| n == name.as_ref()) + } + (false, None) => return Err(invalid()), + }; + if is_hidden { + effective.push(id); + } + // Unmarked groups are visible when registered in the output catalog. + if !is_hidden && name.is_some() { + continue; + } + let marker = is_hidden.to_string(); + let body = match name { + Some(_) => edited(&group, &[(MARKER, marker.as_bytes())])?, + None => { + if group.contains_key(b"Name") && group.get::>(b"Name").is_none() { + return Err(invalid()); + } + renamed = true; + let label = format!("(Placed PDF layer {} {})", id.obj_number, id.gen_number); + edited(&group, &[(MARKER, marker.as_bytes()), (b"Name", label.as_bytes())])? + } + }; + changes.push((id, body)); + } + effective.sort(); + effective.dedup(); + let data = if changes.is_empty() { bytes.clone() } else { Arc::new(append(bytes, &changes, xref.root_id())?) }; + let mut warnings = Vec::new(); + if fallback { + warnings.push("layer state not readable, all layers shown unless hidden by Object Layer Options"); + } + if incomplete { + warnings.push("layer list incomplete; layers named in the default configuration were added"); + } + if renamed { + warnings.push("layer display name not readable; generated a name, visibility preserved"); + } + let warning = if warnings.is_empty() { None } else { Some(warnings.join("; ")) }; + Ok(Variant { data, hidden: effective, warning, has_optional_content: true }) +} + +pub(crate) fn register(bytes: &[u8]) -> Result> { + #[cfg(test)] + crate::export::registration_tests::REGISTRATIONS.with(|count| count.set(count.get() + 1)); + let pdf = Pdf::new(bytes.to_vec()).map_err(|_| invalid())?; + let mut groups = Vec::new(); + let mut changes = Vec::new(); + for object in pdf.objects() { + let Object::Dict(dict) = object else { continue }; + if dict.get::>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"OCG") { + continue; + } + // Resource-only OCGs without a catalog configuration are visible by default. + let hidden = if dict.contains_key(MARKER) { dict.get::(MARKER).ok_or_else(invalid)? } else { false }; + let id = dict.obj_id().ok_or_else(invalid)?; + groups.push((id, hidden)); + changes.push((id, edited(&dict, &[(MARKER, b"")])?)); + } + if groups.is_empty() { + return Ok(bytes.to_vec()); + } + groups.sort_by_key(|(id, _)| *id); + let refs = |hidden_only: bool| { + groups + .iter() + .filter(|(_, hidden)| !hidden_only || *hidden) + .map(|(id, _)| format!("{} {} R", id.obj_number, id.gen_number)) + .collect::>() + .join(" ") + }; + let all = refs(false); + let properties = format!("<>>>", refs(true)); + let root = pdf.xref().root_id(); + let catalog = pdf.xref().get::>(root).ok_or_else(invalid)?; + changes.push((root, edited(&catalog, &[(b"OCProperties", properties.as_bytes())])?)); + append(bytes, &changes, root) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn visible_named_groups_keep_original_buffer_with_repaired_xref() { + let bytes = Arc::new( + b"%PDF-1.5\n1 0 obj\n<>>>>>\nendobj\n\ + 2 0 obj\n<>\nendobj\n\ + 3 0 obj\n<>>>\nendobj\n\ + 4 0 obj\n<>\nendobj\n\ + trailer\n<>\nstartxref\n1\n%%EOF\n" + .to_vec(), + ); + assert!(Pdf::new(bytes.clone()).is_ok()); + + let variant = prepare(&bytes, &[]).unwrap(); + + assert!(Arc::ptr_eq(&bytes, &variant.data), "visible groups must not copy the complete PDF or require incremental writes"); + assert!(variant.hidden.is_empty()); + assert!(variant.warning.is_none()); + } +} diff --git a/crates/pdf/src/registration_tests.rs b/crates/pdf/src/registration_tests.rs new file mode 100644 index 000000000..e69d87051 --- /dev/null +++ b/crates/pdf/src/registration_tests.rs @@ -0,0 +1,204 @@ +use super::*; + +fn placed_document(source: Vec) -> Document { + use designcraft_doc::{Asset, Graphic, ItemId, Shape}; + let mut doc = Document::new(&Default::default()); + let asset = AssetId(doc.alloc()); + doc.add_asset(Asset { id: asset, name: "placed.pdf".into(), mime: "application/pdf".into(), data: Arc::new(source), ..Default::default() }); + let mut item = + Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + item.content = Content::Graphic(Graphic { + asset, + size: (100.0, 100.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + doc +} + +fn layered_source(catalog: bool) -> Vec { + let properties = if catalog { "/OCProperties<>>>" } else { "" }; + let content = b"/OC /a BDC 0.125 0.625 0.875 rg 0 0 100 100 re f EMC"; + let objects = [ + format!("<>"), + "<>".into(), + "<>>>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{}\nendstream", content.len(), String::from_utf8_lossy(content)), + "<>".into(), + ]; + assemble(&objects) +} + +fn unlayered_source() -> Vec { + let content = "0.125 0.625 0.875 rg 0 0 100 100 re f"; + assemble(&[ + "<>".into(), + "<>".into(), + "<>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ]) +} + +fn assemble(objects: &[String]) -> Vec { + let mut bytes = b"%PDF-1.5\n".to_vec(); + let mut offsets = Vec::new(); + for (index, body) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", index + 1).as_bytes()); + } + let xref = bytes.len(); + let size = objects.len() + 1; + bytes.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f\r\n").as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + bytes +} + +thread_local! { + pub(crate) static REGISTRATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn registrations(action: impl FnOnce()) -> usize { + REGISTRATIONS.set(0); + action(); + REGISTRATIONS.get() +} + +fn check_vector_artwork(pdf: &hayro_syntax::Pdf) { + use hayro_syntax::object::{Name, Object}; + + let mut found = false; + for object in pdf.objects() { + let Object::Stream(stream) = object else { continue }; + let subtype = stream.dict().get::>(b"Subtype"); + assert_ne!(subtype.as_ref().map(AsRef::as_ref), Some(b"Image".as_slice()), "placed artwork must remain vector"); + if subtype.as_ref().map(AsRef::as_ref) == Some(b"Form".as_slice()) { + let content = stream.decoded().unwrap(); + let content = String::from_utf8_lossy(&content); + found |= content.contains("0.125 0.625 0.875 rg") && content.contains("0 0 100 100 re f"); + } + } + assert!(found, "placed rectangle and its distinctive fill colour must survive in a vector Form XObject"); +} + +fn check_unlayered_pdf(bytes: &[u8], pages: usize, artwork: bool) { + use hayro_syntax::object::Dict; + + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + assert_eq!(pdf.pages().len(), pages); + let catalog = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + assert!(!catalog.contains_key(b"OCProperties"), "unlayered output must not acquire optional content"); + if artwork { + check_vector_artwork(&pdf); + } +} + +fn check_unlayered(doc: &Document, artwork: bool) { + let cache = Cache::new(); + let mut bytes = Vec::new(); + assert_eq!( + registrations(|| { + bytes = export_pdf(doc, &cache, &PdfOptions::default()).unwrap(); + check_unlayered_pdf(&bytes, 1, artwork); + }), + 0 + ); + assert_eq!( + registrations(|| { + let report = export_booklet(doc, &cache, &BookletOptions::default()).unwrap(); + check_unlayered_pdf(&report.bytes, 2, artwork); + }), + 0 + ); + assert_eq!( + registrations(|| { + let merged = merge_pdfs(&[(bytes, vec![(100.0, 100.0)])], None).unwrap(); + check_unlayered_pdf(&merged, 1, artwork); + }), + 0 + ); +} + +#[test] +fn no_placed_pdfs_skip_optional_content_registration() { + check_unlayered(&Document::new(&Default::default()), false); +} + +#[test] +fn unlayered_placed_pdf_skips_optional_content_registration() { + check_unlayered(&placed_document(unlayered_source()), true); +} + +#[test] +fn layered_and_resource_only_inputs_register_optional_content() { + for hidden in [true, false] { + let source = layered_source(hidden); + let doc = placed_document(source.clone()); + let cache = Cache::new(); + let mut exported = Vec::new(); + assert_eq!( + registrations(|| { + exported = export_pdf(&doc, &cache, &PdfOptions::default()).unwrap(); + }), + 1 + ); + check_registered(&exported, hidden); + assert_eq!( + registrations(|| { + let report = export_booklet(&doc, &cache, &BookletOptions::default()).unwrap(); + check_registered(&report.bytes, hidden); + }), + 1 + ); + for input in [source, exported] { + assert_eq!( + registrations(|| { + let merged = merge_pdfs(&[(input, vec![(100.0, 100.0)])], None).unwrap(); + check_registered(&merged, hidden); + }), + 1 + ); + } + } +} + +fn check_registered(bytes: &[u8], hidden: bool) { + use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier}; + + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + check_vector_artwork(&pdf); + let xref = pdf.xref(); + let catalog = xref.get::>(xref.root_id()).unwrap(); + let properties = catalog.get::>(b"OCProperties").unwrap(); + let groups = properties.get::>(b"OCGs").unwrap(); + assert_eq!(groups.raw_iter().count(), 1); + let group_id: ObjectIdentifier = groups.raw_iter().next().unwrap().as_obj_ref().unwrap().into(); + let group = xref.get::>(group_id).unwrap(); + assert_eq!(group.get::>(b"Type").unwrap().as_ref(), b"OCG"); + assert_eq!(group.get::>(b"Name").unwrap().as_bytes(), b"Hidden"); + + let config = properties.get::>(b"D").unwrap(); + if hidden { + let off = config.get::>(b"OFF").unwrap(); + assert_eq!(off.raw_iter().count(), 1); + let off_id: ObjectIdentifier = off.raw_iter().next().unwrap().as_obj_ref().unwrap().into(); + assert_eq!(off_id, group_id, "OFF must reference the Hidden OCG listed in OCGs"); + } else if config.contains_key(b"OFF") { + assert_eq!(config.get::>(b"OFF").unwrap().raw_iter().count(), 0); + } + // Superseded revisions may retain the marker; readers use only the objects resolved by the current xref. + for object in pdf.objects() { + let Object::Dict(dict) = object else { continue }; + if dict.get::>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"OCG") { + assert!(!dict.contains_key(b"DesignCraftHiddenOCG"), "internal marker remains in resolved OCG {:?}", dict.obj_id()); + } + } +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 1aaf435d9..655669c6a 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -318,7 +318,7 @@ impl Exporter<'_> { if let Some(clip) = to_path(&c.clip.to_path(0.1)) { s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0)))); - self.graphic(s, g); + self.graphic(s, g, &[]); s.pop(); s.pop(); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8ae902a21..9e8f70e8c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -394,7 +394,8 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document and in IDML's spread transform; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. - Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. -- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. +- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen. Plain and PDF/X-4 exports retain vector artwork and optional content with the source defaults or per-frame overrides, including independent states for repeated placements. Missing or unreadable default configurations and unsupported base states fall back to all-ON defaults with a file-named warning, preserving explicit frame overrides. Readable groups omitted from `/OCGs` are added with a distinct file-named warning, preserving their default ON/OFF states; concurrent recovery warnings are combined. Malformed ON data is recoverable only when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides remain errors. PDF/A-2b command exports retain the existing 300 ppi raster path for explicit overrides; PDF/A-2 permits optional content, but this change does not extend archival validation. IDML GraphicLayer visibility import remains unsupported. +- Placed-PDF export preparation is cached per asset and hidden-name set; repeated placements retain recovery warnings (identical warnings are deduplicated). When all groups are visible and named, the original source bytes are embedded without an incremental layer update, including sources whose broken cross-reference tables the reader repairs. Plain, PDF/X-4, booklet and book-merge output register those unmarked groups as visible. Hidden groups or generated names still require a usable cross-reference table; damage errors identify the placed file, and export retains the first placement error rather than replacing it with a later failure. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). - Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width. - View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups). From a84faee829c9e414bc2aa3afc181a9baa6fcd9be Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:45:00 -0600 Subject: [PATCH 12/71] PDF: a hidden layer missing from the placed file aborts vector export Main now aborts an export when a stored hidden layer can no longer be applied (#876). Vector export follows it: a layer hidden in Object Layer Options that the placed PDF doesn't have, because the file has no layers or was relinked, fails with an error naming the file instead of exporting everything visible. The image-path tests for oversized images and undecodable content export as PDF/A-2b, which still rasterizes placed PDFs with hidden layers. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/it/pdf_backend.rs | 12 +++++------- crates/pdf/src/export.rs | 13 +++++++++++-- crates/pdf/src/optional_content.rs | 21 ++++++++++++++++++++- crates/pdf/src/registration_tests.rs | 2 +- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/crates/engine/tests/it/pdf_backend.rs b/crates/engine/tests/it/pdf_backend.rs index 5344dc6f7..e68cbae81 100644 --- a/crates/engine/tests/it/pdf_backend.rs +++ b/crates/engine/tests/it/pdf_backend.rs @@ -118,11 +118,7 @@ fn exported(hidden: bool) -> Vec { session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap(); } let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap(); - let bytes = base64_decode(report["base64"].as_str().unwrap()); - if hidden { - assert!(report["warnings"].to_string().contains("hidden layers")); - } - bytes + base64_decode(report["base64"].as_str().unwrap()) } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] @@ -185,7 +181,8 @@ fn oversized_image_does_not_silently_disappear_from_layered_export() { .unwrap(); session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap(); let before = session.execute("file.serialize", &json!({})).unwrap(); - let result = session.execute("file.exportPdf", &json!({})); + // PDF/A-2b has no optional content: placed PDFs with hidden layers go out as images there. + let result = session.execute("file.exportPdf", &json!({"standard": "pdfa2b"})); assert!(result.is_err(), "export must report the image omitted by the backend's resource limit"); assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "a failed export must leave the document unchanged"); let error = result.unwrap_err().to_string(); @@ -317,7 +314,8 @@ fn failed_content_decode_aborts_layered_export_without_changing_the_document() { .unwrap(); session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap(); let before = session.execute("file.serialize", &json!({})).unwrap(); - let error = session.execute("file.exportPdf", &json!({})).unwrap_err().to_string(); + // PDF/A-2b has no optional content: placed PDFs with hidden layers go out as images there. + let error = session.execute("file.exportPdf", &json!({"standard": "pdfa2b"})).unwrap_err().to_string(); assert!(error.contains("broken-content.pdf") && error.contains("content"), "{filter}: {error}"); assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "{filter}"); } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 61fea9ac2..db7c77ae4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1970,7 +1970,15 @@ mod image_content_cache_tests { let mut doc = Document::new(&Default::default()); let cache = Cache::new(); let opts = PdfOptions::default(); - let source = export_pdf(&doc, &cache, &opts).unwrap(); + // One layer, "Hidden", shown by default: hiding it needs a prepared copy, showing it doesn't. + let content = "/OC /a BDC 0 0 1 rg 0 0 100 100 re f EMC"; + let source = registration_tests::assemble(&[ + "<>>>".into(), + "<>".into(), + "<>>>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>".into(), + ]); let a = asset(&mut doc, source.clone(), "a.pdf"); let b = asset(&mut doc, source, "b.pdf"); let mut ex = exporter(&doc, &cache, &opts); @@ -1994,7 +2002,8 @@ mod image_content_cache_tests { assert_eq!(ex.pdf_preparations, count, "count actual preparations, not writer resource deduplication"); } assert_eq!(ex.variants.len(), 3); - assert_eq!(ex.pdfs.len(), 2); + // a, b, and a with its layer hidden (a copy of its own). + assert_eq!(ex.pdfs.len(), 3); let variant = ex.variants.get(&(a, Vec::new())).unwrap(); assert!(Arc::ptr_eq(&variant.data, &doc.assets[&a].data)); surface.finish(); diff --git a/crates/pdf/src/optional_content.rs b/crates/pdf/src/optional_content.rs index 8bd795937..6214f043f 100644 --- a/crates/pdf/src/optional_content.rs +++ b/crates/pdf/src/optional_content.rs @@ -11,6 +11,12 @@ fn invalid() -> PdfError { PdfError::Write("the placed PDF's optional-content state could not be preserved safely".into()) } +/// A layer hidden in Object Layer Options that the placed file doesn't have (relinked, say): +/// exporting would be a guess at what was meant to stay hidden. +fn missing_layer() -> PdfError { + PdfError::Write("a layer hidden in Object Layer Options is not in the placed PDF; relink it or reset its layer options".into()) +} + fn references(dict: &Dict<'_>, key: &[u8]) -> Result> { if !dict.contains_key(key) { return Ok(Vec::new()); @@ -128,6 +134,9 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"OCG"))); + if !hidden.is_empty() { + return Err(missing_layer()); + } return Ok(Variant { data: bytes.clone(), hidden: Vec::new(), warning: None, has_optional_content }); } let properties = root.get::>(b"OCProperties").ok_or_else(invalid)?; @@ -165,6 +174,7 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result>(id).ok_or_else(invalid)?; if group.get::>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"OCG") { @@ -175,7 +185,13 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result !fallback && (off.contains(&id) || (base_off && !on.contains(&id))), (false, Some(name)) => { let name = String::from_utf8_lossy(name.as_bytes()); - hidden.iter().any(|n| n == name.as_ref()) + let mut is_hidden = false; + for (n, found) in hidden.iter().zip(&mut found) { + if n == name.as_ref() { + (*found, is_hidden) = (true, true); + } + } + is_hidden } (false, None) => return Err(invalid()), }; @@ -200,6 +216,9 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result Vec { ]) } -fn assemble(objects: &[String]) -> Vec { +pub(super) fn assemble(objects: &[String]) -> Vec { let mut bytes = b"%PDF-1.5\n".to_vec(); let mut offsets = Vec::new(); for (index, body) in objects.iter().enumerate() { From ea698361468c85f54a95b9c9b5f7395fe9548d96 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:33:22 -0600 Subject: [PATCH 13/71] PDF: carry an explicit layer choice and read trailers structurally Export takes the placed PDF's layer choice as file defaults or an explicit hidden list, where an explicit empty list shows every layer, including one the file hides by default. Today an empty list still means file defaults; an explicit all-on state can map straight onto Some(&[]). The incremental writer now reads each cross-reference section's trailer by walking the table rather than searching for the keyword, follows the bounded Prev chain for inherited encryption, and treats a literal null /Encrypt as absent. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/export.rs | 21 ++-- crates/pdf/src/optional_content.rs | 191 +++++++++++++++++++++++++---- crates/pdf/src/text.rs | 2 +- 3 files changed, 181 insertions(+), 33 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index db7c77ae4..193491324 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -294,7 +294,7 @@ fn merge_pdfs_inner(parts: &[(Vec, Vec<(f32, f32)>)], title: Option<&str>) - let mut pages = 0; let mut has_optional_content = false; for (bytes, sizes) in parts { - let variant = crate::optional_content::prepare(&Arc::new(bytes.clone()), &[])?; + let variant = crate::optional_content::prepare(&Arc::new(bytes.clone()), None)?; has_optional_content |= !sizes.is_empty() && variant.has_optional_content; let src = krilla::pdf::Pdf::new(variant.data).map_err(|e| PdfError::Write(format!("{e:?}")))?; let doc = krilla::pdf::PdfDocument::new(Arc::new(src)); @@ -518,7 +518,7 @@ pub(crate) struct Exporter<'a> { image_conversions: usize, /// Placed PDFs (embedded as vector pages) and their page counts. pdfs: HashMap<(AssetId, Vec), Option<(krilla::pdf::PdfDocument, usize)>>, - variants: HashMap<(AssetId, Vec), crate::optional_content::Variant>, + variants: HashMap<(AssetId, Option>), crate::optional_content::Variant>, has_optional_content: bool, #[cfg(test)] pdf_preparations: usize, @@ -1000,7 +1000,7 @@ impl Exporter<'_> { let g = &if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() }; if let Some(p) = &path { s.push_clip_path(p, &FillRule::NonZero); - self.graphic(s, g, &it.pdf_hidden_layers); + self.graphic(s, g, (!it.pdf_hidden_layers.is_empty()).then_some(it.pdf_hidden_layers.as_slice())); s.pop(); } } @@ -1406,8 +1406,9 @@ impl Exporter<'_> { img } - /// Draw a placed graphic, inside its clipping path when it has one. - pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: &[String]) { + /// Draw a placed graphic, inside its clipping path when it has one. `hidden` is the + /// placed PDF's layer choice: `None` keeps the file's defaults, `Some(&[])` shows every layer. + pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: Option<&[String]>) { match g.clip_region().map(|(region, even_odd)| (to_path(®ion), even_odd)) { Some((Some(clip), even_odd)) => { s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero }); @@ -1424,7 +1425,7 @@ impl Exporter<'_> { } } - fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: &[String]) { + fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: Option<&[String]>) { // Video / sound without a poster: a dark frame with a play mark. if let Some(asset) = self.doc.assets.get(&g.asset) && designcraft_doc::media_kind(&asset.mime).is_some() @@ -1458,7 +1459,7 @@ impl Exporter<'_> { let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t)); let Some(size) = Size::from_wh(page_w.max(1e-3) as f32, page_h.max(1e-3) as f32) else { return }; - let request = (g.asset, hidden.to_vec()); + let request = (g.asset, hidden.map(<[String]>::to_vec)); let variant = match self.variants.get(&request) { Some(variant) => variant.clone(), None => { @@ -1985,7 +1986,7 @@ mod image_content_cache_tests { let mut writer = Writer::new(Default::default(), Default::default()); let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(100.0, 100.0).unwrap())); let mut surface = page.surface(); - for (id, hidden, count) in [(a, vec![], 1), (a, vec![], 1), (a, vec!["Hidden".into()], 2), (b, vec![], 3), (a, vec![], 3)] { + for (id, hidden, count) in [(a, None, 1), (a, None, 1), (a, Some(vec!["Hidden".into()]), 2), (b, None, 3), (a, None, 3)] { let graphic = designcraft_doc::Graphic { asset: id, size: (100.0, 100.0), @@ -1997,14 +1998,14 @@ mod image_content_cache_tests { wrap: Default::default(), fill: Default::default(), }; - ex.graphic_unclipped(&mut surface, &graphic, &hidden); + ex.graphic_unclipped(&mut surface, &graphic, hidden.as_deref()); assert!(ex.layer_error.is_none()); assert_eq!(ex.pdf_preparations, count, "count actual preparations, not writer resource deduplication"); } assert_eq!(ex.variants.len(), 3); // a, b, and a with its layer hidden (a copy of its own). assert_eq!(ex.pdfs.len(), 3); - let variant = ex.variants.get(&(a, Vec::new())).unwrap(); + let variant = ex.variants.get(&(a, None)).unwrap(); assert!(Arc::ptr_eq(&variant.data, &doc.assets[&a].data)); surface.finish(); page.finish(); diff --git a/crates/pdf/src/optional_content.rs b/crates/pdf/src/optional_content.rs index 6214f043f..0dd0c4879 100644 --- a/crates/pdf/src/optional_content.rs +++ b/crates/pdf/src/optional_content.rs @@ -55,29 +55,77 @@ fn edited(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])]) -> Result> { Ok(out) } +/// The trailer of the cross-reference section at `offset`: a table's `trailer` dictionary or an +/// xref stream's own. A table is read entry by entry rather than searched for the keyword, so a +/// `trailer` in a comment can't stand in for the real one; reading is bounded either way. +fn trailer_at(bytes: &[u8], offset: usize) -> Option> { + const MAX_SECTION_BYTES: usize = 4 * 1024 * 1024; + let data = bytes.get(offset..)?; + let mut reader = Reader::new(data.get(..data.len().min(MAX_SECTION_BYTES))?); + reader.skip_white_spaces_and_comments(); + if reader.forward_tag(b"xref").is_none() { + reader.read_without_context::()?; + reader.skip_white_spaces_and_comments(); + let stream = reader.read_without_context::>()?; + let is_xref = stream.dict().get::>(b"Type").as_ref().map(AsRef::as_ref) == Some(b"XRef"); + return is_xref.then(|| stream.dict().clone()); + } + // Every pass consumes input or fails, and the input is bounded, so this ends. + loop { + reader.skip_white_spaces(); + if reader.forward_tag(b"trailer").is_some() { + break; + } + reader.read_without_context::()?; + reader.skip_white_spaces(); + let count = reader.read_without_context::()?; + if count > MAX_SECTION_BYTES / 18 { + return None; + } + for _ in 0..count { + reader.skip_white_spaces(); + reader.read_without_context::()?; + reader.skip_white_spaces(); + if reader.read_without_context::()? > 65535 { + return None; + } + reader.skip_white_spaces(); + reader.forward_tag(b"n").or_else(|| reader.forward_tag(b"f"))?; + } + } + reader.skip_white_spaces_and_comments(); + reader.read_without_context::>() +} + +/// Whether `dict` has `key`: a literal `null` value is the same as no entry. A reference counts, +/// since a trailer has no context to resolve it in. +fn has_entry(dict: &Dict<'_>, key: &[u8]) -> bool { + dict.contains_key(key) && !matches!(dict.get_raw::>(key), Some(MaybeRef::NotRef(Object::Null(_)))) +} + fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec)], root: ObjectIdentifier) -> Result> { let damaged = || PdfError::Write("the placed PDF's cross-reference table is damaged; layer changes could not be preserved safely".into()); let at = crate::transitions::rfind(bytes, b"startxref").ok_or_else(damaged)?; let tail = std::str::from_utf8(bytes.get(at..).ok_or_else(damaged)?).map_err(|_| damaged())?; let prev = crate::transitions::int_after(tail, "startxref").ok_or_else(damaged)?; - let section = bytes.get(prev..).ok_or_else(damaged)?; - let trailer = if section.starts_with(b"xref") { - let at = crate::transitions::find(section, b"trailer", 0).ok_or_else(damaged)?; - let mut reader = Reader::new(section.get(at + 7..).ok_or_else(damaged)?); - reader.skip_white_spaces_and_comments(); - reader.read_without_context::>().ok_or_else(damaged)? - } else { - let mut reader = Reader::new(section); - reader.read_without_context::().ok_or_else(damaged)?; - reader.skip_white_spaces_and_comments(); - let stream = reader.read_without_context::>().ok_or_else(damaged)?; - if stream.dict().get::>(b"Type").as_ref().map(AsRef::as_ref) != Some(b"XRef") { + let trailer = trailer_at(bytes, prev).ok_or_else(damaged)?; + // Never write decrypted objects into an encrypted file. An earlier update's trailer can + // carry the encryption, so follow the (bounded, acyclic) Prev chain to its end. + let mut offset = Some(prev); + let mut visited = std::collections::HashSet::new(); + for _ in 0..32 { + let Some(at) = offset else { break }; + if !visited.insert(at) { return Err(damaged()); } - stream.dict().clone() - }; - if trailer.contains_key(b"Encrypt") { - return Err(invalid()); + let section = trailer_at(bytes, at).ok_or_else(damaged)?; + if has_entry(§ion, b"Encrypt") { + return Err(invalid()); + } + offset = section.get::(b"Prev"); + } + if offset.is_some() { + return Err(damaged()); } trailer.get::(b"Size").filter(|n| *n > 0).ok_or_else(damaged)?; let mut out = bytes.to_vec(); @@ -125,7 +173,10 @@ pub(crate) struct Variant { pub has_optional_content: bool, } -pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result { +/// The placed PDF with its layer choice applied: `None` keeps the file's own default +/// visibility, `Some(names)` shows every layer except `names` (so `Some(&[])` is all on). +pub(crate) fn prepare(bytes: &Arc>, choice: Option<&[String]>) -> Result { + let hidden = choice.unwrap_or_default(); let pdf = Pdf::new(bytes.clone()).map_err(|_| invalid())?; let xref = pdf.xref(); let root = xref.get::>(xref.root_id()).ok_or_else(invalid)?; @@ -181,9 +232,10 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result>(b"Name"); - let is_hidden = match (hidden.is_empty(), name.as_ref()) { - (true, _) => !fallback && (off.contains(&id) || (base_off && !on.contains(&id))), - (false, Some(name)) => { + let is_hidden = match (choice, name.as_ref()) { + (None, _) => !fallback && (off.contains(&id) || (base_off && !on.contains(&id))), + (Some([]), None) => false, + (Some(_), Some(name)) => { let name = String::from_utf8_lossy(name.as_bytes()); let mut is_hidden = false; for (n, found) in hidden.iter().zip(&mut found) { @@ -193,7 +245,7 @@ pub(crate) fn prepare(bytes: &Arc>, hidden: &[String]) -> Result return Err(invalid()), + (Some(_), None) => return Err(invalid()), }; if is_hidden { effective.push(id); @@ -289,10 +341,105 @@ mod tests { ); assert!(Pdf::new(bytes.clone()).is_ok()); - let variant = prepare(&bytes, &[]).unwrap(); + let variant = prepare(&bytes, None).unwrap(); assert!(Arc::ptr_eq(&bytes, &variant.data), "visible groups must not copy the complete PDF or require incremental writes"); assert!(variant.hidden.is_empty()); assert!(variant.warning.is_none()); } + + /// One page with layer "Off", hidden by the file's default configuration. `trailer` is the + /// trailer dictionary; `previous` an earlier revision's trailer, chained by /Prev. + fn default_hidden(trailer: &str, previous: Option<&str>) -> Arc> { + let mut bytes = b"%PDF-1.5\n".to_vec(); + let mut offsets = Vec::new(); + for body in [ + "<>>>>>", + "<>", + "<>>>", + "<>", + ] { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + let table = |bytes: &mut Vec, trailer: &str| { + let at = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); + for offset in &offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n{trailer}\n").as_bytes()); + at + }; + let trailer = match previous { + Some(previous) => { + let at = table(&mut bytes, previous); + trailer.replace(">>", &format!("/Prev {at}>>")) + } + None => trailer.to_owned(), + }; + let at = table(&mut bytes, &trailer); + bytes.extend_from_slice(format!("startxref\n{at}\n%%EOF\n").as_bytes()); + Arc::new(bytes) + } + + #[test] + fn an_explicit_choice_overrides_a_layer_the_file_hides() { + let bytes = default_hidden("<>", None); + + let defaults = prepare(&bytes, None).unwrap(); + let all_on = prepare(&bytes, Some(&[])).unwrap(); + + let group = ObjectIdentifier::new(4, 0); + assert_eq!(defaults.hidden, vec![group], "file defaults keep the layer hidden"); + assert!(all_on.hidden.is_empty(), "an explicit all-on choice shows it"); + let registered = register(&all_on.data).unwrap(); + let pdf = Pdf::new(registered).unwrap(); + let catalog = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + let config = catalog.get::>(b"OCProperties").unwrap().get::>(b"D").unwrap(); + assert_eq!(references(&config, b"OFF").unwrap(), Vec::new()); + } + + /// The incremental writer alone, so a reader's own handling of the input can't decide the result. + fn update(bytes: &[u8]) -> Result> { + append(bytes, &[(ObjectIdentifier::new(4, 0), b"<>".to_vec())], ObjectIdentifier::new(1, 0)) + } + + #[test] + fn a_null_encrypt_entry_is_no_encryption() { + for trailer in ["<>", "<>"] { + assert!(update(&default_hidden(trailer, None)).is_ok(), "{trailer}"); + } + for trailer in ["<>", "<>>>"] { + assert!(update(&default_hidden(trailer, None)).is_err(), "{trailer}"); + } + // End to end, a null entry still lets a layer choice be written. + assert!(prepare(&default_hidden("<>", None), None).is_ok()); + } + + #[test] + fn encryption_in_an_earlier_revision_is_found() { + let bytes = default_hidden("<>", Some("<>")); + assert!(update(&bytes).is_err()); + let bytes = default_hidden("<>", Some("<>")); + assert!(update(&bytes).is_ok()); + } + + #[test] + fn a_trailer_in_a_comment_does_not_hide_the_real_one() { + let bytes = b"xref\n% trailer <>\n0 1\n0000000000 65535 f\r\ntrailer\n<>"; + assert!(trailer_at(bytes, 0).is_none(), "a comment inside the table is not table syntax"); + let bytes = b"xref\n0 1\n0000000000 65535 f\r\ntrailer\n<>"; + assert!(has_entry(&trailer_at(bytes, 0).unwrap(), b"Encrypt")); + } + + #[test] + fn a_cyclic_prev_chain_is_damage() { + let mut bytes = default_hidden("<>", None).to_vec(); + let at = crate::transitions::rfind(&bytes, b"xref\n").unwrap(); + let patched = String::from_utf8(bytes.clone()).unwrap().replacen("<>", &format!("<>"), 1); + bytes = patched.into_bytes(); + let error = update(&bytes).unwrap_err().to_string(); + assert!(error.contains("cross-reference"), "{error}"); + } } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 655669c6a..8f33a020b 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -318,7 +318,7 @@ impl Exporter<'_> { if let Some(clip) = to_path(&c.clip.to_path(0.1)) { s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0)))); - self.graphic(s, g, &[]); + self.graphic(s, g, None); s.pop(); s.pop(); } From 4c580086a0addbb2f28fb4377ad3c1138f9daba7 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:50:42 -0600 Subject: [PATCH 14/71] PDF: a null OCProperties is no optional content; an unreadable Prev is damage A placed file whose catalog has /OCProperties null (literal or through a reference) exported on main but failed after the layer-choice change; treat it as absent. Like the trailer guard in #1670, a non-null /Prev that can't be read as an offset ends nothing and is reported as a damaged cross-reference table. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/optional_content.rs | 16 +++++++++++++++- crates/pdf/src/registration_tests.rs | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/optional_content.rs b/crates/pdf/src/optional_content.rs index 0dd0c4879..3a9e09289 100644 --- a/crates/pdf/src/optional_content.rs +++ b/crates/pdf/src/optional_content.rs @@ -123,6 +123,9 @@ fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec)], root: ObjectIde return Err(invalid()); } offset = section.get::(b"Prev"); + if offset.is_none() && has_entry(§ion, b"Prev") { + return Err(damaged()); + } } if offset.is_some() { return Err(damaged()); @@ -180,7 +183,8 @@ pub(crate) fn prepare(bytes: &Arc>, choice: Option<&[String]>) -> Result let pdf = Pdf::new(bytes.clone()).map_err(|_| invalid())?; let xref = pdf.xref(); let root = xref.get::>(xref.root_id()).ok_or_else(invalid)?; - if !root.contains_key(b"OCProperties") { + // A null value, literal or referenced, is the same as no entry. + if matches!(root.get::>(b"OCProperties"), None | Some(Object::Null(_))) { let has_optional_content = pdf .objects() .into_iter() @@ -433,6 +437,16 @@ mod tests { assert!(has_entry(&trailer_at(bytes, 0).unwrap(), b"Encrypt")); } + #[test] + fn an_unreadable_prev_is_damage_and_a_null_one_ends_the_chain() { + for value in ["(12)", "/Twelve", "-1", "[12]", "<<>>", "true", "9 0 R"] { + let bytes = default_hidden(&format!("<>"), None); + let error = update(&bytes).unwrap_err().to_string(); + assert!(error.contains("cross-reference"), "{value}: {error}"); + } + assert!(update(&default_hidden("<>", None)).is_ok()); + } + #[test] fn a_cyclic_prev_chain_is_damage() { let mut bytes = default_hidden("<>", None).to_vec(); diff --git a/crates/pdf/src/registration_tests.rs b/crates/pdf/src/registration_tests.rs index d87c4c838..278100304 100644 --- a/crates/pdf/src/registration_tests.rs +++ b/crates/pdf/src/registration_tests.rs @@ -137,6 +137,25 @@ fn unlayered_placed_pdf_skips_optional_content_registration() { check_unlayered(&placed_document(unlayered_source()), true); } +#[test] +fn null_optional_content_properties_are_no_optional_content() { + let content = "0.125 0.625 0.875 rg 0 0 100 100 re f"; + for (properties, extra) in [("/OCProperties null", None), ("/OCProperties 5 0 R", Some("null"))] { + let mut objects = vec![ + format!("<>"), + "<>".into(), + "<>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ]; + objects.extend(extra.map(String::from)); + let doc = placed_document(assemble(&objects)); + for standard in [Standard::None, Standard::PdfX4] { + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + check_unlayered_pdf(&bytes, 1, true); + } + } +} + #[test] fn layered_and_resource_only_inputs_register_optional_content() { for hidden in [true, false] { From f5e1bda7ccdf3ed291c9bc10eacfcd85d762475e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 01:14:04 -0700 Subject: [PATCH 15/71] Preflight: the missing-link test uses a path that is absolute on Windows too a_missing_link_shown_by_its_preview_is_still_reported stored the link /definitely/missing dir/photo.jpg in a document without a path. On Windows that path names no drive, so document_path refuses it as not absolute and the preflight message ends with "(the path is not valid)", and the test failed. The test now stores a link under std::env::temp_dir(), an absolute path with the default temporary-folder settings on Windows, macOS and Linux, and expects the message for that path. The preflight code is unchanged. --- crates/engine/src/cmd/preflight.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 19d64c1fc..2d9698ba1 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -354,11 +354,15 @@ mod placed_vector_tests { s.execute("file.place", &json!({"base64": base64_encode(&png), "name": "photo.jpg", "x": 72, "y": 72, "width": 20})).unwrap(); let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let asset = doc.assets.values_mut().next().unwrap(); - std::sync::Arc::make_mut(asset).link = Some("/definitely/missing dir/photo.jpg".into()); + // The temporary folder is an absolute path with the default settings on Windows, macOS and Linux. On Windows, + // `/x` names no drive, so `document_path` refuses it when the document has no path. + let link = std::env::temp_dir().join(format!("dc-preflight-missing-{} dir", std::process::id())).join("photo.jpg"); + let link = link.to_string_lossy().into_owned(); + std::sync::Arc::make_mut(asset).link = Some(link.clone()); let r = s.execute("preflight.run", &json!({})).unwrap(); let missing: Vec<&str> = r["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "missingLink").map(|i| i["message"].as_str().unwrap()).collect(); - assert_eq!(missing, ["Missing link: /definitely/missing dir/photo.jpg"]); + assert_eq!(missing, [format!("Missing link: {link}")]); } } From 333cdf116fb136d598805ff383b05b12493d64ed Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 01:53:04 -0700 Subject: [PATCH 16/71] Package: the Windows redirect test falls back to a directory junction package_refuses_windows_directory_redirects made its redirect with symlink_dir. Without Developer Mode or elevation that call fails with os error 1314 (ERROR_PRIVILEGE_NOT_HELD), which std does not report as PermissionDenied, so the skip arm did not match and the test panicked. When symlink_dir fails, the test now makes a directory junction with mklink /J, which needs no privilege. is_redirect treats a junction as a redirect, so the test still checks that File > Package refuses the redirected folder. It skips only when neither can be made. The package code is unchanged. --- crates/engine/src/cmd/package.rs | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index d0ed58b03..ffc69b39e 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -907,13 +907,12 @@ mod tests { let outside = base.join("separate"); std::fs::create_dir_all(&outside).unwrap(); let redirected = base.join("Package"); - match symlink_dir(&outside, &redirected) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { - let _ = std::fs::remove_dir_all(&base); - return; - } - Err(e) => panic!("could not create directory redirect: {e}"), + // A symbolic link needs Developer Mode or elevation (os error 1314 without them); a + // directory junction needs neither, and `is_redirect` treats both as redirects. + if symlink_dir(&outside, &redirected).is_err() && !make_junction(&outside, &redirected) { + eprintln!("skipped: directory redirects can't be made here"); + let _ = std::fs::remove_dir_all(&base); + return; } let mut s = Session::new(); s.execute("file.new", &json!({"title": "Annual"})).unwrap(); @@ -922,4 +921,17 @@ mod tests { std::fs::remove_dir(&redirected).unwrap(); let _ = std::fs::remove_dir_all(&base); } + + /// Makes `link` a directory junction to `target` with `mklink /J`, and reports whether it was made. + #[cfg(windows)] + fn make_junction(target: &Path, link: &Path) -> bool { + use std::os::windows::process::CommandExt; + + // Windows paths can't hold `"`, so quoting each one keeps `&` and spaces in it literal for cmd. + std::process::Command::new("cmd") + .args(["/D", "/C"]) + .raw_arg(format!("mklink /J \"{}\" \"{}\"", link.display(), target.display())) + .output() + .is_ok_and(|output| output.status.success()) + } } From c11006c832ab321eae6d9b647bcbed1398a376f8 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:55:13 +0200 Subject: [PATCH 17/71] Effects dialog: the Angle with Use Global Light sets the global light The Angle field was greyed out with Use Global Light on. It now shows the document's global light angle and changes it for every effect that uses it, applied with the effects in one undo step. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 30 ++++++++++++++- crates/ui-egui/src/effects_dialog.rs | 56 ++++++++++++++++++++++++---- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 153555913..fbc6e01cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-23** · Effects dialog: with Use Global Light on, the Angle field (and the Bevel and Emboss one) stays editable and shows the document's global light angle; a change there sets the global light for every effect that uses it, applied with the effects in one undo step (`object.effects` takes `globalLight`). Before, the field was greyed out and the angle couldn't be changed in the dialog. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. - **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 87f601604..24c44b83a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -584,7 +584,7 @@ pub fn specs() -> Vec { "Effects", [], None, - "{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, ids?} — every effect at once, one undo step", + "{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, globalLight? (the document's global light angle, as an Angle field with Use Global Light sets it), ids?} — every effect at once, one undo step", has_selection, effects_all ), @@ -2293,8 +2293,16 @@ fn effects_all(s: &mut Session, p: &Value) -> Result { Some(BlendMode::parse(raw).ok_or_else(|| bad(ID, format!("unknown blend mode `{raw}`")))?) } }; + // The document's global light angle, changed from an Angle field with Use Global Light on. + let global_light = match p.get("globalLight") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|a| a.is_finite()).ok_or_else(|| bad(ID, "globalLight must be a number"))?), + }; let ids = targets(s, p)?; s.edit(|d, _| { + if let Some(a) = global_light { + d.settings.global_light = (a + 180.0).rem_euclid(360.0) - 180.0; + } let mut changed = 0; for id in &ids { let Some(it) = d.item_mut(*id) else { continue }; @@ -4099,6 +4107,26 @@ mod light_tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(angle(&s), 45.0); } + + /// The Effects dialog's Angle with Use Global Light on changes the global light, with the + /// effects, in one undo step. + #[test] + fn effects_sets_the_global_light_with_the_effects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.dropShadow", &json!({"on": true, "globalLight": true, "ids": [b]})).unwrap(); + let before = s.doc().unwrap().doc.settings.global_light; + s.execute("object.effects", &json!({"ids": [a], "effects": {"dropShadow": {"on": true, "globalLight": true}}, "globalLight": 30})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.global_light, 30.0); + let other = &d.item(ItemId(b)).unwrap().effects.drop_shadow; + assert_eq!(d.light_angle(other.angle, other.global_light), 30.0, "every shadow on Global Light follows"); + assert!(s.execute("object.effects", &json!({"ids": [a], "globalLight": "up"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.global_light, before, "one undo step"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 456ca4a58..bcfe4b26a 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -116,10 +116,17 @@ fn is_on(fx: &Value, page: &str) -> bool { /// The `object.effects` params the dialog stands for. pub fn params(d: &Dialog) -> Value { - json!({"ids": d.fields.get("ids").cloned().unwrap_or(json!([])), "effects": d.fields.get("fx").cloned().unwrap_or(json!({})), - "opacity": d.fields.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), "blend": d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal")}) + let mut p = json!({"ids": d.fields.get("ids").cloned().unwrap_or(json!([])), "effects": d.fields.get("fx").cloned().unwrap_or(json!({})), + "opacity": d.fields.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), "blend": d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal")}); + if let Some(a) = d.fields.get(GLOBAL_LIGHT).and_then(Value::as_f64) { + p["globalLight"] = json!(a); + } + p } +/// The global light angle as changed in the dialog (an Angle field with Use Global Light on). +const GLOBAL_LIGHT: &str = "globalLight"; + /// Apply: the preview goes and the settings are made once, as one undo step. pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { if d.fields.contains_key(PREVIEWED) { @@ -202,12 +209,19 @@ fn percent(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) { } } -fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], enabled: bool, global: f64) { - let mut v = if enabled { num(d, path) } else { global }; - let r = ui.add_enabled(enabled, egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)); +/// The effect's own angle at `path`, or with Use Global Light (`own` false) the document's +/// global light angle: editing it then changes that one, for every effect that uses it. +fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], own: bool, global: f64) -> egui::Response { + let mut v = if own { num(d, path) } else { global }; + let r = ui.add(egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)); if r.changed() { - set(d, path, json!(v)); + if own { + set(d, path, json!(v)); + } else { + d.fields.insert(GLOBAL_LIGHT.into(), json!(v)); + } } + r } fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], label: &str) { @@ -273,7 +287,9 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let lang = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let unit = app.session.active().map_or(Unit::Points, |st| st.doc.settings.horizontal_units); - let global = app.session.active().map_or(120.0, |st| st.doc.settings.global_light); + // The global light angle as the dialog will set it (an edit in it shows at once). + let global = + d.fields.get(GLOBAL_LIGHT).and_then(Value::as_f64).unwrap_or_else(|| app.session.active().map_or(120.0, |st| st.doc.settings.global_light)); let row = |ui: &mut egui::Ui, l: &str| crate::rtl::label(ui, tr(l)); crate::rtl::label(ui, egui::RichText::new(tr(PAGES.iter().find(|p| p.0 == page).map_or("Transparency", |p| p.1))).font(semibold(14.0))); let grid = |id: &str| egui::Grid::new(("fx_grid", id.to_string())).num_columns(4).spacing([8.0, 6.0]); @@ -657,6 +673,32 @@ mod tests { texts } + /// With Use Global Light on, the Angle is the document's global light and stays editable: + /// a change there is applied to the document with the effects (it was greyed out). + #[test] + fn the_angle_with_global_light_sets_the_global_light() { + let (mut app, _) = app_with_frame(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + crate::menus::activate(&mut app, "object.dropShadow", &Value::Null); + let d = app.ui.dialog.as_mut().unwrap(); + set(d, &["dropShadow", "globalLight"], json!(true)); + frame(&mut app, &ctx); + // The field shows the global angle and is enabled: a DragValue that isn't disabled. + let global = app.session.doc().unwrap().doc.settings.global_light; + let mut d = app.ui.dialog.clone().unwrap(); + let ui_ctx = egui::Context::default(); + let mut enabled = false; + let mut out = ui_ctx.run_ui(egui::RawInput::default(), |ui| { + enabled = angle(ui, &mut d, &["dropShadow", "angle"], false, global).enabled(); + }); + out.textures_delta.clear(); + assert!(enabled, "the Angle stays editable with Use Global Light"); + d.fields.insert(GLOBAL_LIGHT.into(), json!(15.0)); + confirm(&mut app, &d).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.settings.global_light, 15.0); + } + #[test] fn set_makes_the_path_and_ignores_an_empty_one() { let mut d = Dialog::new(ID, json!({"fx": {}})); From f0ce96138c6c32ec7d9052483cb47eee7308dce3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:07:37 +0000 Subject: [PATCH 18/71] Saturate footnote and endnote numbering at u32::MAX Found while testing crates/compose/src/notes.rs:133 (also crates/doc/src/notes.rs:265 footnote_start and crates/doc/src/endnotes.rs:150 endnote_start) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/compose/src/notes.rs | 2 +- crates/doc/src/endnotes.rs | 2 +- crates/doc/src/notes.rs | 2 +- crates/engine/src/cmd/notes.rs | 11 +++++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 2a465fbfa..02878e65a 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -130,7 +130,7 @@ impl Notes { self.num = o.start_at; } self.numbers[k] = self.num; - self.num += 1; + self.num = self.num.saturating_add(1); sub.notes.insert(self.refs[k], o.label(self.numbers[k], false)); } sub.note_position = o.ref_position; diff --git a/crates/doc/src/endnotes.rs b/crates/doc/src/endnotes.rs index da213d08c..0d54d2379 100644 --- a/crates/doc/src/endnotes.rs +++ b/crates/doc/src/endnotes.rs @@ -147,7 +147,7 @@ impl crate::Document { if st.id == sid { break; } - n += st.endnotes.len() as u32; + n = n.saturating_add(st.endnotes.len() as u32); } n } diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index c4d83969a..0811c549c 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -262,7 +262,7 @@ impl crate::Document { let Some(me) = self.story(sid) else { return o.start_at }; let mine = key(me); let before: usize = self.stories.values().filter(|st| st.id != sid && !st.notes.is_empty() && key(st) < mine).map(|st| st.notes.len()).sum(); - o.start_at + before as u32 + o.start_at.saturating_add(before as u32) } /// Absolute page an item sits on (its center), None on parent pages / pasteboard-only spreads. diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index 9318458de..03dde5c40 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -221,4 +221,15 @@ mod tests { // Caret is now in footnote text: no nested footnotes. assert!(s.execute("footnote.insert", &json!({})).is_err()); } + + #[test] + fn huge_start_at_does_not_overflow() { + let (mut s, sid) = session_with_text(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap(); + s.execute("footnote.insert", &json!({"text": "x"})).unwrap(); + s.execute("footnote.options", &json!({"startAt": 4294967295u32})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, None); + assert_eq!(cs.frames[0].notes.len(), 1); + } } From 332b0d9f5cbb1ed5ac1d6f06aba7ee66db939970 Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 24 Oct 2026 16:07:21 +0500 Subject: [PATCH 19/71] links: a linked file is read under a cap, not whole (#1596) Update, Relink and Relink Folder all reload a linked graphic through `reload`, which read the file with an unbounded `read`. A document whose link points at a multi-GB file exhausted memory on Update. How large a linked file is belongs to whoever wrote the document, not to whoever opens it: the path comes out of the document. `reload` now reads through `read_capped` at the limit packaging already applies to a linked file (`MAX_LINKED_FILE_BYTES`, 256 MB), and a file over it is refused by name instead of read. `reload_capped` takes the cap so a test can show both sides without a 256 MB fixture. Not changed: `file.place` reads a path the same unbounded way, but there the user picked the file, where a link's path can come from a document someone else wrote. Whether that read wants the same cap is a call for a maintainer, so it is reported rather than changed. Found in the audit of #1596 (DC-15). --- crates/engine/src/cmd/links.rs | 41 +++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index c39e05668..335b65b50 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -396,8 +396,23 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi #[cfg(not(target_arch = "wasm32"))] fn reload(path: impl AsRef) -> Result<(Vec, (u32, u32))> { + reload_capped(path, super::interchange::MAX_LINKED_FILE_BYTES) +} + +/// A linked graphic read again from disk, and its pixel size, reading at most `cap` bytes. +/// +/// How large a linked file is belongs to whoever wrote the document, not to whoever opens it: the +/// path comes out of the document, and Update, Relink and Relink Folder all follow it. An +/// unbounded `read` of a link pointing at a multi-GB file exhausted memory (#1596). The cap is +/// the one packaging already applies to a linked file, and a file over it is refused by name +/// rather than read. +#[cfg(not(target_arch = "wasm32"))] +fn reload_capped(path: impl AsRef, cap: u64) -> Result<(Vec, (u32, u32))> { let path = path.as_ref(); - let b = crate::io::read_file(path).map_err(EngineError::Other)?; + let b = crate::io::read_capped(path, cap).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + if b.len() as u64 > cap { + return Err(bad("links", format!("{}: the linked file is larger than {} MB", path.display(), cap / (1024 * 1024)))); + } if designcraft_render::is_pdf(&b) { designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?; } @@ -584,6 +599,30 @@ mod tests { designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png() } + /// A linked file's size is the document's choice, so a link is read under a cap (#1596). + #[test] + fn a_linked_file_over_the_cap_is_refused_instead_of_read() { + let dir = std::env::temp_dir().join(format!("dc-link-cap-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("big.png"); + let bytes = png(40, 40); + std::fs::write(&path, &bytes).unwrap(); + + // Under the cap the file is read and measured as before. + let (read, px) = reload_capped(&path, bytes.len() as u64).expect("a file within the cap is read"); + assert_eq!((read.len(), px), (bytes.len(), (40, 40))); + + // Over it the file is refused by name, and the error says what the limit is. + let err = reload_capped(&path, (bytes.len() - 1) as u64).expect_err("a file over the cap is refused"); + let msg = err.to_string(); + assert!(msg.contains("big.png") && msg.contains("larger than"), "the error names the file and the limit: {msg}"); + + // The real cap is the one packaging uses, so an ordinary graphic still loads. + assert!(reload(&path).is_ok(), "a normal linked graphic is unaffected"); + let _ = std::fs::remove_dir_all(&dir); + } + fn pdf(sizes: &[(u32, u32)]) -> Vec { let mut s = Session::new(); s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap(); From 7808816e9a5b92f1e3493f94d2249f7354de1935 Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 24 Oct 2026 16:17:49 +0500 Subject: [PATCH 20/71] import: a value a file gives is one the engine can mean (#1593) The importers each had their own spelling of "is this a number we can use", or only checked that it was finite. Measured before the change: -> Some(1e300) pt tagged text -> Some(-5.0) pt -> Some(0.0) pt w:spacing before=1e300 -> 5e298 pt indent DOCX DC-12 asks for a shared clamp helper used by every importer, so there are two in `doc::attrs`, beside the tab limits: * `imported_type_size` drops a size the engine cannot mean, so the text keeps the size its style gives it -- which is what RTF and DOCX already did. * `imported_measure` clamps an indent, spacing or offset to the widest page. Clamped rather than dropped on purpose: a file that says "indent everything" still indents as far as the engine goes, where dropping would quietly restore the style's own indent and hide what the file asked for. Both are now used by tagged text, DOCX runs and paragraphs, DOCX numbering levels and RTF -- the last two had the 0.1-1296 pt range as a literal, which is the duplication that let tagged text drift. Control: with the helpers back to a finite-only test, both new tests fail, the DOCX one printing the 5e298 pt spacing in full. Stale in the issue: its first location, `docx.rs` `w:sz`, is already clamped -- that came with #1574. DC-12's remaining holes are the ones fixed here. --- crates/doc/src/attrs.rs | 28 +++++++++++++++++++++++ crates/textimport/src/docx.rs | 30 ++++++++++++++++++++++--- crates/textimport/src/docx_numbering.rs | 3 ++- crates/textimport/src/rtf.rs | 2 +- crates/textimport/src/tagged.rs | 22 +++++++++++++++++- 5 files changed, 79 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 7c57736ef..43cc06aec 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -650,6 +650,34 @@ pub const MAX_TAB_LEADER: usize = 8; /// Stops closer than this share a position. pub const SAME_TAB_POSITION: f64 = 0.01; +/// Smallest type size the engine accepts, in points. +pub const MIN_TYPE_SIZE: f64 = 0.1; +/// Largest type size the engine accepts, in points, as InDesign's own field allows. +pub const MAX_TYPE_SIZE: f64 = 1296.0; +/// Largest measure an imported indent, spacing or offset can carry, in points: the furthest tab +/// stop, which is the widest page. +pub const MAX_MEASURE: f64 = MAX_TAB_POSITION; + +/// A type size read from a file, or `None` when it is not a size the engine can mean. +/// +/// A file can hold any number, including `NaN`, `inf`, `0`, a negative or `1e300`. Every importer +/// had its own spelling of this test, or only checked that the value was finite -- so tagged text +/// took `` and set a 1e300 pt size on the text (#1593). A size outside the range is +/// dropped rather than clamped, so the text keeps the size its style gives it. +pub fn imported_type_size(pt: f64) -> Option { + (pt.is_finite() && (MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&pt)).then_some(pt) +} + +/// An indent, spacing or offset read from a file, clamped to ±[`MAX_MEASURE`]; `None` when the +/// value is not finite. +/// +/// Unlike a type size this is clamped rather than dropped: a file that says "indent everything" +/// still indents to the widest page the engine has, where dropping the value would quietly +/// restore the style's own indent and hide what the file asked for (#1593). +pub fn imported_measure(pt: f64) -> Option { + pt.is_finite().then(|| pt.clamp(-MAX_MEASURE, MAX_MEASURE)) +} + impl TabStop { /// A finite position clamped to `0..=MAX_TAB_POSITION`. pub fn clamp_position(v: f64) -> Result { diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 87aaa3359..faa086435 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -186,8 +186,8 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs { if let Some(s) = r.on("strike") { a.strikethrough = Some(s); } - // Half-points; sizes outside the engine's 0.1–1296 pt range (or non-finite) are dropped. - if let Some(pt) = r.val("sz").and_then(|v| v.parse::().ok()).map(|sz| sz / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)) { + // Half-points; a size the engine cannot mean is dropped (#1593). + if let Some(pt) = r.val("sz").and_then(|v| v.parse::().ok()).map(|sz| sz / 2.0).and_then(designcraft_doc::attrs::imported_type_size) { a.size = Some(pt); } if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) { @@ -210,7 +210,8 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs { fn para_attrs(ppr: Option<&El>) -> ParaAttrs { let mut a = ParaAttrs::default(); let Some(p) = ppr else { return a }; - let tw = |v: Option<&str>| v.and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0); + // Twips, clamped: a file can hold 1e300, which became a 5e298 pt indent (#1593). + let tw = |v: Option<&str>| v.and_then(|v| v.parse::().ok()).map(|v| v / 20.0).and_then(designcraft_doc::attrs::imported_measure); if let Some(sp) = p.child("spacing") { a.space_before = tw(sp.attr("before")); a.space_after = tw(sp.attr("after")); @@ -633,6 +634,29 @@ mod tests { assert_eq!(part_capped(&mut zip, "word/missing.xml", 4096).unwrap(), None); } + /// A DOCX can hold any number where a measure belongs: `1e300` twips became a 5e298 pt + /// indent, and `w:sz` of `1e300` a 5e299 pt type size. Measures are clamped to the widest + /// page and a size the engine cannot mean is dropped (#1593). + #[test] + fn a_measure_or_size_a_file_cannot_mean_is_bounded() { + use designcraft_doc::attrs::MAX_MEASURE; + let doc = format!( + r#" + + big"# + ); + let i = import(&docx(&doc, "", "")).unwrap(); + let p = &i.story.paras[0].para; + for (what, v) in + [("space before", p.space_before), ("space after", p.space_after), ("left indent", p.left_indent), ("first line", p.first_line_indent)] + { + let v = v.expect("the value is kept, bounded"); + assert!(v.is_finite() && v.abs() <= MAX_MEASURE, "{what} is within the widest page: {v}"); + } + let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size; + assert_eq!(size, None, "a size the engine cannot mean is dropped, so the style's size stands"); + } + #[test] fn imports_styles_runs_footnotes_and_tables() { let styles = format!( diff --git a/crates/textimport/src/docx_numbering.rs b/crates/textimport/src/docx_numbering.rs index e3837e720..2a7d7873a 100644 --- a/crates/textimport/src/docx_numbering.rs +++ b/crates/textimport/src/docx_numbering.rs @@ -129,7 +129,8 @@ fn number_style(fmt: &str) -> Format { } 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); + // Twips, clamped as elsewhere: a level's indent is a measure, not any number (#1593). + let tw = |v: Option<&str>| v.and_then(|v| v.trim().parse::().ok()).map(|v| v / 20.0).and_then(designcraft_doc::attrs::imported_measure); 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)), diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index 782071798..6254ca668 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -229,7 +229,7 @@ pub fn import(bytes: &[u8]) -> Result { "strike" => restyle(&|s| s.strike = on), // Half-points; sizes outside the engine's 0.1–1296 pt range fall back to the default. "fs" => restyle(&|s| { - s.size = param.map(|p| p as f64 / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)); + s.size = param.map(|p| p as f64 / 2.0).and_then(designcraft_doc::attrs::imported_type_size); }), "plain" => restyle(&|s| { s.bold = false; diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index 3a890734a..74806ae4c 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -201,7 +201,9 @@ pub fn import(bytes: &[u8]) -> Result { } "cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()), "cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| value.to_string()), - "cSize" => fmt.over.size = value.trim().parse().ok().filter(|s: &f64| s.is_finite()), + // A size the engine cannot mean (0, negative, 1e300) is dropped, so the text + // keeps its style's size (#1593). + "cSize" => fmt.over.size = value.trim().parse().ok().and_then(designcraft_doc::attrs::imported_type_size), "DefineParaStyle" | "DefineCharStyle" => { let nm = value.split('=').next().unwrap_or("").trim().to_string(); let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles }; @@ -276,6 +278,24 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + /// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a + /// negative, and tagged text took all three and set them on the text (#1593). They are now + /// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size. + #[test] + fn a_char_size_the_engine_cannot_mean_is_dropped() { + for v in ["1e300", "-5", "0", "1297", "0.09"] { + let src = format!("\r\nbig"); + let i = import(src.as_bytes()).unwrap(); + let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size; + assert_eq!(size, None, "cSize {v} is not a size the engine can mean"); + } + // A size inside the range still arrives. + let src = "\r\nbig"; + let i = import(src.as_bytes()).unwrap(); + let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size; + assert_eq!(size, Some(18.0)); + } + /// `f64::from_str` accepts "NaN" and "inf"; a non-finite size must not reach the story. #[test] fn non_finite_char_size_is_ignored() { From e51b2c6114cd2fba858f85841a4975baff585c5b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 04:06:54 -0700 Subject: [PATCH 21/71] =?UTF-8?q?Type=20=E2=80=BA=20Notes=20=E2=80=BA=20Co?= =?UTF-8?q?nvert=20to=20Note:=20the=20selected=20text=20becomes=20a=20note?= =?UTF-8?q?,=20as=20in=20InDesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new command note.convertFromText removes the selected story text and puts it into a new editorial note whose anchor sits where the text was. It takes an optional author, and Convert Note to Text puts the text back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table, and leaves the story unchanged. The item follows New Note in Type › Notes, with translations in the catalogs that already translate Convert Note to Text. --- ROADMAP.md | 1 + crates/engine/src/cmd/editorial.rs | 117 ++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/menus.rs | 1 + docs/target-app-parity.md | 2 +- 15 files changed, 129 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 153555913..57fce9fff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Type › Notes › Convert to Note (`note.convertFromText {author?}`): as in InDesign, the selected text leaves the story and becomes the text of a new note, whose anchor takes its place; Convert Note to Text puts it back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. - **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 5b210da90..6106a1f3e 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -2,11 +2,11 @@ use std::sync::Arc; -use designcraft_doc::{EditorialNote, NOTE_MARK, StoryId}; +use designcraft_doc::{ANCHOR_MARK, ENDNOTE_REF, EditorialNote, FOOTNOTE_REF, INDEX_MARK, NOTE_MARK, OBJECT_MARK, StoryId, TABLE_ANCHOR, XREF_MARK}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param}; -use crate::Result; +use crate::{Result, Session}; pub fn specs() -> Vec { vec![ @@ -37,6 +37,15 @@ pub fn specs() -> Vec { Ok(json!({"story": ts.story.0, "id": id})) }) }), + cmd!( + "note.convertFromText", + "Convert to Note", + ["Type", "Notes"], + None, + "{author?} → {story, id}. The selected text moves into a new note anchored where it was.", + has_text_range, + convert_from_text + ), cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| { let (sid, id) = target(p)?; let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string)); @@ -96,6 +105,55 @@ pub fn specs() -> Vec { ] } +/// Characters whose payload lives outside the story text: a note made from text that holds one +/// would lose it. +const MARKS: [char; 8] = [NOTE_MARK, ENDNOTE_REF, FOOTNOTE_REF, OBJECT_MARK, INDEX_MARK, ANCHOR_MARK, XREF_MARK, TABLE_ANCHOR]; + +fn has_text_range(s: &Session) -> std::result::Result<(), String> { + has_text(s)?; + match s.active().and_then(|d| d.selection.text) { + Some(t) if t.cell.is_some() => Err("notes go in story text".into()), + Some(t) if t.is_caret() => Err("select the text to convert".into()), + _ => Ok(()), + } +} + +/// Type › Notes › Convert to Note: the selected text leaves the story and becomes the text of a +/// new note, whose anchor takes the text's place. +fn convert_from_text(s: &mut Session, p: &Value) -> Result { + const ID: &str = "note.convertFromText"; + let ts = s.doc()?.selection.text.ok_or_else(|| bad(ID, "select the text to convert"))?; + if ts.cell.is_some() { + return Err(bad(ID, "notes go in story text")); + } + let r = ts.range(); + if r.is_empty() { + return Err(bad(ID, "select the text to convert")); + } + let author = str_param(p, "author").unwrap_or("").to_string(); + s.edit(|d, sel| { + let story = d.story_mut(ts.story).ok_or_else(|| bad(ID, "no such story"))?; + let text = story.text.get(r.clone()).ok_or_else(|| bad(ID, "the selection is outside the story"))?.to_string(); + if text.contains(MARKS) { + return Err(bad(ID, "the selection holds a table, an anchored object, a note, a footnote, an endnote or a marker")); + } + let fmt = story.format_after(r.start).clone(); + story.delete(r.clone()); + story.insert_with(r.start, &NOTE_MARK.to_string(), fmt); + story.fix_marks(); + let k = story.text[..r.start].matches(NOTE_MARK).count(); + let n = story.editorial.get_mut(k).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?; + let n = Arc::make_mut(n); + (n.text, n.author) = (text, author); + let id = n.id; + if let Some(t) = &mut sel.text { + let c = r.start + NOTE_MARK.len_utf8(); + (t.anchor, t.focus) = (c, c); + } + Ok(json!({"story": ts.story.0, "id": id})) + }) +} + fn target(p: &Value) -> Result<(StoryId, u64)> { let sid = p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("note", "`story` required"))?; let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("note", "`id` required"))?; @@ -159,4 +217,59 @@ mod tests { assert_eq!(st.endnotes[1].text.text, "An endnote."); assert_eq!(st.notes[1].text.text, "A footnote."); } + + #[test] + fn selected_text_converts_to_a_note_at_its_place() { + 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": "One two\nthree four."})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let mark = designcraft_doc::NOTE_MARK.len_utf8(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + s.execute("note.new", &json!({"text": "earlier"})).unwrap(); + // "two\nthree " across the paragraph return, selected backwards. + let (a, b) = (3 + mark + 1, 3 + mark + 1 + "two\nthree ".len()); + s.execute("text.select", &json!({"story": r["story"], "anchor": b, "focus": a})).unwrap(); + let n = s.execute("note.convertFromText", &json!({"author": "Ed"})).unwrap(); + let st = &s.doc().unwrap().doc.stories[&sid]; + st.check().unwrap(); + assert_eq!(st.text, format!("One{m} {m}four.", m = designcraft_doc::NOTE_MARK)); + assert_eq!(st.paras.len(), 1, "the paragraph return went into the note"); + let list = s.execute("note.list", &json!({})).unwrap(); + assert_eq!(list[0]["text"], "earlier"); + assert_eq!((list[1]["id"].clone(), list[1]["at"].clone()), (n["id"].clone(), json!(a))); + assert_eq!((list[1]["text"].as_str(), list[1]["author"].as_str()), (Some("two\nthree "), Some("Ed"))); + let caret = s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus)); + assert_eq!(caret, Some((a + mark, a + mark)), "the insertion point follows the anchor"); + s.execute("note.convertToText", &json!({"story": n["story"], "id": n["id"]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.stories[&sid].text, format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let st = &s.doc().unwrap().doc.stories[&sid]; + assert_eq!((st.text.as_str(), st.editorial.len()), (format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK).as_str(), 1)); + } + + #[test] + fn convert_to_note_needs_selected_story_text_without_objects() { + 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": "Draft copy."})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": r["story"], "anchor": 5, "focus": 5})).unwrap(); + let disabled = |r| matches!(r, Err(crate::EngineError::Disabled(..))); + assert!(disabled(s.execute("note.convertFromText", &json!({}))), "an insertion point selects no text"); + s.execute("note.new", &json!({"text": "keep"})).unwrap(); + let before = s.doc().unwrap().doc.stories[&sid].clone(); + let all = before.len(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": all})).unwrap(); + let refused = s.execute("note.convertFromText", &json!({})); + assert!(matches!(refused, Err(crate::EngineError::BadParams { .. })), "a note inside the selection would be lost"); + assert_eq!(s.doc().unwrap().doc.stories[&sid], before); + s.execute("text.select", &json!({"story": r["story"], "anchor": all, "focus": all})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.insert", &json!({"text": "cell", "raw": true})).unwrap(); + s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); + assert!(s.doc().unwrap().selection.text.is_some_and(|t| t.cell.is_some() && !t.is_caret())); + assert!(disabled(s.execute("note.convertFromText", &json!({}))), "notes go in story text"); + } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index bdca83589..b7ae97ce6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1613,6 +1613,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Convert to Form Field", ["Convert to Form Field", "Convertir en champ de formulaire", "Convert to Form Field", "Convert to Form Field", "转换为表单域"], ), + ("Convert to Note", ["Convert to Note", "Convertir en note", "Convert to Note", "Convert to Note", "转换为注释"]), ("Conveyor", ["Conveyor", "Convoyeur", "Conveyor", "Conveyor", "传送带"]), ("Copy Link(s) To…", ["Copy Link(s) To…", "Copier les liens vers…", "Copy Link(s) To…", "Copy Link(s) To…", "复制链接至…"]), ("Corner Options…", ["Corner Options…", "Options d'arrondi…", "Corner Options…", "Corner Options…", "角选项…"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..0411342e5 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1341,6 +1341,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "تحويل النص إلى جدول"), ("Convert to Button", "تحويل إلى زر"), ("Convert to Form Field", "تحويل إلى حقل نموذج"), + ("Convert to Note", "تحويل إلى ملاحظة"), ("Copy Link(s) To…", "نسخ الروابط إلى…"), ("Corner Options…", "خيارات الزوايا…"), ("Create Alternate Layout…", "إنشاء تخطيط بديل…"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..ba3ea6f3e 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -470,6 +470,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Převést text na tabulku"), ("Convert to Button", "Převést na tlačítko"), ("Convert to Form Field", "Převést na pole formuláře"), + ("Convert to Note", "Převést na poznámku"), ("Conveyor", "Dopravník"), ("Copy Link(s) To…", "Kopírovat propojení do…"), ("Corner Options…", "Možnosti rohů…"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 367ca52f8..8846683f3 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -521,6 +521,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Text in Tabelle umwandeln"), ("Convert to Button", "In Schaltfläche umwandeln"), ("Convert to Form Field", "In Formularfeld umwandeln"), + ("Convert to Note", "In Notiz umwandeln"), ("Conveyor", "Förderband"), ("Copy Link(s) To…", "Verknüpfung(en) kopieren nach …"), ("Corner Options…", "Eckenoptionen …"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..08cf12586 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -598,6 +598,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Converti testo in tabella"), ("Convert to Button", "Converti in pulsante"), ("Convert to Form Field", "Converti in campo modulo"), + ("Convert to Note", "Converti in nota"), ("Conveyor", "Nastro"), ("Copy Link(s) To…", "Copia collegamenti in…"), ("Corner Options…", "Opzioni angoli…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 37b14ac5c..ba7286728 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -713,6 +713,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "テキストを表に変換"), ("Convert to Button", "ボタンに変換"), ("Convert to Form Field", "フォームフィールドに変換"), + ("Convert to Note", "注釈に変換"), ("Conveyor", "コンベヤー"), ("Copy Link(s) To…", "リンクを指定先にコピー…"), ("Corner Options…", "角のオプション…"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..de7eb4387 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -525,6 +525,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "ტექსტის ცხრილად გარდაქმნა"), ("Convert to Button", "ღილაკად გარდაქმნა"), ("Convert to Form Field", "ფორმის ველად გარდაქმნა"), + ("Convert to Note", "შენიშვნად გარდაქმნა"), ("Conveyor", "კონვეიერი"), ("Copy Link(s) To…", "ბმული ფაილების კოპირება…"), ("Corner Options…", "კუთხეების პარამეტრები…"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..cbfc7d6c4 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -477,6 +477,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Zamień tekst na tabelę"), ("Convert to Button", "Zamień na przycisk"), ("Convert to Form Field", "Zamień na pole formularza"), + ("Convert to Note", "Zamień na notatkę"), ("Conveyor", "Przenośnik"), ("Copy Link(s) To…", "Kopiuj łącza do…"), ("Corner Options…", "Opcje narożników…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..bbf7ac8d9 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1478,6 +1478,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("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"), + ("Convert to Note", "Converter em nota"), ("Copy Link(s) To…", "Copiar link(s) para…"), ("Corner Options…", "Opções de canto…"), ("Create Frame", "Criar quadro"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..6aef2b1b2 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -457,6 +457,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Преобразовать текст в таблицу"), ("Convert to Button", "Преобразовать в кнопку"), ("Convert to Form Field", "Преобразовать в поле формы"), + ("Convert to Note", "Преобразовать в заметку"), ("Conveyor", "Конвейер"), ("Copy Link(s) To…", "Копировать связанные файлы в…"), ("Corner Options…", "Параметры углов…"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..eac694fa0 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -601,6 +601,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Перетворити текст на таблицю"), ("Convert to Button", "Перетворити на кнопку"), ("Convert to Form Field", "Перетворити на поле форми"), + ("Convert to Note", "Перетворити на примітку"), ("Conveyor", "Конвеєр"), ("Copy Link(s) To…", "Копіювати пов’язані файли до…"), ("Corner Options…", "Параметри кутів…"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3c052b783..b93e40b33 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -484,6 +484,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", ">Notes", "cmd:note.new", + "cmd:note.convertFromText", "ui:window.panel|Notes|{\"panel\": \"notes\"}", "<", ">Track Changes", diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8ae902a21..2dfc486d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -324,7 +324,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place). - Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions. - Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. -- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported. +- Editorial notes (`note.new/edit/delete/convertToText/convertFromText/list`, Notes panel, Type › Notes › Convert to Note): anchored in text, flagged on screen, never printed or exported. - File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. - Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, CMYK spreads with separable blend modes use two ink-channel passes through the working colour settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens coloured areas while paper stays outside the blend backdrop. Effect blend layers sit outside their clips and offscreen filters, preserving their backdrop in RGB too. The pass count is independent of blended-object count, and unchanged viewports reuse one cached raster (64 MiB cap). Spreads containing Hue, Saturation, Color or Luminosity still use the existing RGB blend followed by a CMYK proof approximation; full nonseparable CMYK blending remains open. - High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). From d5a6a7ddd52b99bcecd8375dd98da164416b6432 Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 24 Oct 2026 16:26:39 +0500 Subject: [PATCH 22/71] GREP styles: a scan and the pattern cache are bounded (#1598) Two unbounded things in the GREP-style path. `BACKTRACK_LIMIT` bounds one search, not a scan: `find_all` was called with `usize::MAX` and pays the limit again for every match, so a pattern tuned just under it cost `200_000 x matches` per paragraph on every relayout. A scan now stops at `MAX_MATCHES_PER_PARAGRAPH` (4096) -- more styled runs than a paragraph can usefully carry -- and says which pattern reached it. The `PATTERNS` thread-local never evicted, so it grew across every pattern a session ever composed. It now holds at most 64, least recently used dropped first: a tick counter marks each use, so the entry evicted is the one longest unused rather than the one inserted earliest. The test checks that the pattern just used is still there after the cache overflows, which insertion-order eviction would not give. Left for a separate change: DC-17 also asks for a per-compose step budget, so the cost cannot add up across paragraphs. That means threading a budget from `compose_story` through `shape` into `overlays`, a compose-internal signature change that wants its own review rather than riding along here. --- crates/compose/src/grep.rs | 7 ++++ crates/compose/src/overlay.rs | 77 +++++++++++++++++++++++++++++++---- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 1d7e0e6a9..09c6459e0 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -23,6 +23,13 @@ use designcraft_doc::story; pub const MAX_PATTERN: usize = 4096; /// Backtracking steps one search may take before it gives up. pub const BACKTRACK_LIMIT: usize = 200_000; +/// Matches one GREP style may contribute to one paragraph. +/// +/// [`BACKTRACK_LIMIT`] bounds a single search, not a scan: `find_all` pays it again for every +/// match, so a pattern tuned just under the limit cost `BACKTRACK_LIMIT × matches` per paragraph +/// on every relayout (#1598). A paragraph cannot usefully carry more styled runs than this, and +/// a scan that reaches it stops with what it has. +pub const MAX_MATCHES_PER_PARAGRAPH: usize = 4096; /// Compiled program size cap (bytes) for the underlying regex engine. const SIZE_LIMIT: usize = 4 << 20; diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index d65460949..5c95b0ec8 100644 --- a/crates/compose/src/overlay.rs +++ b/crates/compose/src/overlay.rs @@ -9,9 +9,42 @@ use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil}; use crate::grep::{Grep, GrepOptions}; +/// Patterns kept compiled at once. A document's GREP styles are few; the cache grew without +/// bound across every pattern a session ever composed (#1598). +const MAX_CACHED_PATTERNS: usize = 64; + thread_local! { - /// Compiled GREP styles by pattern; None for one that doesn't compile or gave up on a search. - static PATTERNS: RefCell>> = RefCell::new(HashMap::new()); + /// Compiled GREP styles by pattern, with the tick they were last used at; `None` for one that + /// doesn't compile or gave up on a search. Bounded to [`MAX_CACHED_PATTERNS`], least recently + /// used evicted first. + static PATTERNS: RefCell, u64)>> = RefCell::new(HashMap::new()); + /// Counts cache uses, so the least recently used entry can be found. + static TICK: RefCell = const { RefCell::new(0) }; +} + +/// The compiled pattern for `pattern`, compiling it on first use and evicting the least recently +/// used entry once the cache is full. `f` is given `None` when the pattern does not compile. +fn with_pattern(pattern: &str, f: impl FnOnce(&mut Option) -> R) -> R { + let now = TICK.with(|t| { + let mut t = t.borrow_mut(); + *t += 1; + *t + }); + PATTERNS.with(|p| { + let mut p = p.borrow_mut(); + if !p.contains_key(pattern) + && p.len() >= MAX_CACHED_PATTERNS + && let Some(oldest) = p.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()) + { + p.remove(&oldest); + } + let slot = p.entry(pattern.to_string()).or_insert_with(|| { + let re = Grep::new(pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {pattern:?} skipped: {e}")).ok(); + (re, now) + }); + slot.1 = now; + f(&mut slot.0) + }) } fn is_event(u: &NestedUntil, c: char, prev: Option, next: Option) -> bool { @@ -71,14 +104,15 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], if g.style.is_empty() || g.pattern.is_empty() { continue; } - PATTERNS.with(|p| { - let mut p = p.borrow_mut(); - let slot = p.entry(g.pattern.clone()).or_insert_with(|| { - Grep::new(&g.pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {:?} skipped: {e}", g.pattern)).ok() - }); + with_pattern(&g.pattern, |slot| { let Some(re) = slot else { return }; - match re.find_all(text.get(range.start..end).unwrap_or(""), usize::MAX) { - Ok(ms) => out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))), + match re.find_all(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) { + Ok(ms) => { + if ms.len() == crate::grep::MAX_MATCHES_PER_PARAGRAPH { + log::warn!("GREP style {:?} matched more than {} times in one paragraph; the rest are not styled", g.pattern, ms.len()); + } + out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))); + } Err(e) => { log::warn!("GREP style {:?} disabled: {e}", g.pattern); *slot = None; @@ -93,6 +127,31 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], mod tests { use super::*; + /// A GREP style's scan is bounded two ways (#1598): the matches one paragraph can take, and + /// the patterns kept compiled at once. + #[test] + fn a_grep_scan_and_the_pattern_cache_are_bounded() { + // More matches in one paragraph than the cap: the scan stops with what it has instead of + // paying the backtrack limit again for each one. + let text = "a ".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 500); + let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a".into() }], 0); + assert_eq!(o.len(), crate::grep::MAX_MATCHES_PER_PARAGRAPH, "the scan stops at the cap"); + + // The cache holds at most MAX_CACHED_PATTERNS, and the one just used is not the one + // dropped: a document's own styles stay compiled however many patterns a session saw. + PATTERNS.with(|p| p.borrow_mut().clear()); + for i in 0..MAX_CACHED_PATTERNS + 20 { + let pattern = format!("x{i}y"); + overlays("x1y", 0..3, &[], &[GrepStyle { style: "S".into(), pattern }], 0); + } + let (len, has_last) = PATTERNS.with(|p| { + let p = p.borrow(); + (p.len(), p.contains_key(&format!("x{}y", MAX_CACHED_PATTERNS + 19))) + }); + assert_eq!(len, MAX_CACHED_PATTERNS, "the cache is bounded"); + assert!(has_last, "the most recently used pattern is still cached"); + } + #[test] fn nested_and_grep_ranges() { let text = "Q: what is 42? A drop of 7 words.\n".to_string(); From 7074dc8a856a4b63780fca721862570a193ed0bf Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 09:31:08 +0100 Subject: [PATCH 23/71] M6: preserve per-placement graphic layer options --- ROADMAP.md | 3 +- apps/designcraft-cli/src/perf.rs | 1 + crates/compose/src/tests.rs | 3 + crates/doc/src/assets.rs | 32 ++++++++ crates/doc/src/item.rs | 26 ++++++ crates/engine/src/cmd/datamerge/fill.rs | 1 + crates/engine/src/cmd/export.rs | 2 + crates/engine/src/cmd/file.rs | 3 + crates/engine/src/cmd/links.rs | 3 + crates/engine/src/cmd/mathexpr.rs | 1 + crates/engine/src/cmd/media.rs | 2 + crates/engine/src/cmd/preflight.rs | 22 +++++ crates/engine/src/cmd/table.rs | 1 + crates/engine/src/sample.rs | 1 + crates/engine/tests/graphic_layer_options.rs | 82 +++++++++++++++++++ crates/epub/src/lib.rs | 3 + crates/epub/tests/it/grouped_reading_order.rs | 1 + crates/format/src/lib.rs | 14 +++- crates/idml/src/export.rs | 26 ++++++ crates/idml/src/import.rs | 8 ++ crates/idml/src/tests.rs | 1 + crates/idml/src/tests/assets.rs | 71 ++++++++++++++++ crates/pdf/src/export.rs | 4 + crates/pdf/src/pdfx.rs | 1 + crates/pdf/src/tests.rs | 1 + .../support/image_content_cache_contract.rs | 1 + crates/pptx/src/tests.rs | 1 + crates/render/src/lib.rs | 2 + crates/render/tests/it/colorize.rs | 1 + crates/render/tests/it/placed_pdf.rs | 1 + crates/tools/src/tests.rs | 4 + crates/ui-egui/src/panels/properties.rs | 1 + docs/gaps.md | 4 +- docs/graphic-layer-options.md | 40 +++++++++ 34 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 crates/engine/tests/graphic_layer_options.rs create mode 100644 docs/graphic-layer-options.md diff --git a/ROADMAP.md b/ROADMAP.md index 153555913..01c748d33 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,10 @@ # DesignCraft roadmap **Stage: pre-alpha** · next: alpha, ~5–7 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%) +- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md). - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 0c665f7e7..117adbc66 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -239,6 +239,7 @@ pub fn synthetic(spec: &Spec) -> Result { let (nw, nh) = (aw as f64, ah as f64); let s = (r.width() / nw).max(r.height() / nh); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s), diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3fc1c6ccb..85d73ecca 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -577,6 +577,7 @@ fn same_as_clipping_wrap_follows_the_clipping_path() { ); let triangle = PathData::single(SubPath::polyline(&[Point::new(75.0, 0.0), Point::new(150.0, 200.0), Point::new(0.0, 200.0)], true)); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (150.0, 200.0), xf: designcraft_geom::Affine::translate((150.0, 100.0)), @@ -725,6 +726,7 @@ fn an_image_contour_stops_at_its_frame() { let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, frame); // The image overhangs the frame on every side. it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(d.alloc()), size: (300.0, 250.0), xf: designcraft_geom::Affine::IDENTITY, @@ -769,6 +771,7 @@ fn a_clipping_path_contour_stops_at_its_frame() { true, )); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (150.0, 200.0), xf: designcraft_geom::Affine::translate((150.0, 100.0)), diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 0be5604c3..ed7679ee5 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -15,6 +15,37 @@ use crate::{Asset, AssetId, Content, Document, Item}; /// is treated as using every asset, so nothing it references is merged away or pruned. const MAX_DEPTH: usize = 64; +fn item_layer_options(it: &Item, depth: usize, overrides_only: bool) -> bool { + depth > MAX_DEPTH + || it.graphic().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides())) + || it.children().iter().any(|child| item_layer_options(child, depth + 1, overrides_only)) +} + +fn story_layer_options(st: &Story, depth: usize, overrides_only: bool) -> bool { + depth > MAX_DEPTH + || st.objects.iter().any(|object| item_layer_options(&object.item, depth + 1, overrides_only)) + || st.tables.values().flat_map(|table| &table.cells).any(|cell| { + cell.graphic.as_ref().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides())) + || story_layer_options(&cell.text, depth + 1, overrides_only) + }) + || st.notes.iter().chain(&st.endnotes).any(|note| story_layer_options(¬e.text, depth + 1, overrides_only)) +} + +impl Document { + /// Whether saving needs a reader that retains per-placement graphic layer settings. + /// Exceeding the traversal budget conservatively requires the newer format. + pub fn has_graphic_layer_options(&self) -> bool { + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, false)) + || self.stories.values().any(|story| story_layer_options(story, 0, false)) + } + + /// Includes parent items, anchored objects, graphic cells and nested notes. + pub fn has_graphic_layer_visibility_overrides(&self) -> bool { + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) + || self.stories.values().any(|story| story_layer_options(story, 0, true)) + } +} + impl Asset { /// Whether `other` holds the same file: the same type, PDF page and crop, link and bytes. Files at /// different paths stay apart even with identical bytes, since each can be updated or @@ -356,6 +387,7 @@ mod tests { fn graphic_item(id: u64, asset: u64, crop: f64) -> Item { let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); it.content = Content::Graphic(Graphic { + layer_options: None, asset: AssetId(asset), size: (10.0, 10.0), xf: Affine::translate((crop, 0.0)), diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index cfb13dc53..9c4169fe4 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -596,10 +596,34 @@ pub enum Fitting { CenterContent, } +/// Retained IDML layer choices for one placement, independent of shared asset bytes. +/// Scalar attributes are kept verbatim, including view/export/print state and link-update policy. +/// XML Self IDs are regenerated by the exporter and are not stored here. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphicLayerOptions { + pub attributes: std::collections::BTreeMap, + pub layers: Vec>, +} + +pub const GRAPHIC_LAYER_RENDERING_WARNING: &str = + "Placed graphic layer visibility overrides are preserved but are not applied to rendering or PDF output"; + +impl GraphicLayerOptions { + pub fn has_visibility_overrides(&self) -> bool { + self.layers.iter().any(|layer| { + layer.get("CurrentVisibility").is_some_and(|v| Some(v) != layer.get("OriginalVisibility")) + || ["HasViewState", "HasExportState", "HasPrintState"].iter().any(|key| layer.get(*key).is_some_and(|v| v == "true")) + }) + } +} + /// A placed graphic inside a frame. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Graphic { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_options: Option, pub asset: AssetId, /// Natural size of the graphic in points (pixels at its resolution, or PDF page size). pub size: (f64, f64), @@ -1642,6 +1666,7 @@ mod tests { #[test] fn graphics_without_a_clipping_path_load_and_save_as_before() { let g = Graphic { + layer_options: None, asset: AssetId(3), size: (40.0, 30.0), xf: Affine::translate((5.0, 5.0)), @@ -1653,6 +1678,7 @@ mod tests { fill: Default::default(), }; let v = serde_json::to_value(&g).unwrap(); + assert!(v.get("layerOptions").is_none(), "legacy graphics keep their existing representation"); assert!(v.get("clip").is_none(), "{v}"); assert_eq!(serde_json::from_value::(v.clone()).unwrap(), g); // A clip written with only its type and path gets the default options. diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index d4b2235e9..9547830a7 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -369,6 +369,7 @@ fn place_image( }; let mode = fitting_of(&options.fitting); let mut g = Graphic { + layer_options: None, asset: AssetId(0), size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 551dd7484..3797a9bf6 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -434,6 +434,7 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp ); let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w as f64, h as f64), xf: designcraft_geom::Affine::translate((r.x0, r.y0)) @@ -604,6 +605,7 @@ fn rasterize_where( ); let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach)); img_item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w as f64, h as f64), xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index bc6928362..39b92a942 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -710,6 +710,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k); validate_placement((bw, bh), r, xf, "file.place")?; it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (bw, bh), xf, @@ -736,6 +737,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { it.effects = e.clone(); } it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (bw, bh), xf, @@ -900,6 +902,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { validate_placement((nw, nh), frame_rect, xf, "place.drop")?; let r = s.edit(|d, sel| { let graphic = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (nw, nh), xf, diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index c39e05668..83fe16bf1 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1128,6 +1128,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: sid, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1173,6 +1174,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: missing, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1209,6 +1211,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: missing, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index b684f2457..e5342f79c 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -19,6 +19,7 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w, h), xf: Affine::translate(at), diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index 9e26fb0b4..a4e3f5ae6 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -32,6 +32,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; let r = it.inner_bounds(); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (r.width(), r.height()), xf: Affine::translate((r.x0, r.y0)), @@ -52,6 +53,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let r = super::finite_rect("file.place", Rect::new(x, y, x + w, y + h))?; let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w, h), xf: Affine::translate((x, y)), diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 19d64c1fc..c69050ace 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -249,6 +249,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { let xf = parent * it.xf; pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf))); let page = page_of(d, SpreadRef::Doc(si), it, parent); + if let Content::Graphic(g) = &it.content + && g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) + { + out.push(Issue { + severity: "warning", + kind: "unsupportedGraphicLayers", + message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(), + item: Some(it.id.0), + page, + }); + } match &it.content { Content::Graphic(g) => match d.assets.get(&g.asset) { None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }), @@ -307,6 +318,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { } } } + // Nested graphic cells and anchored/parent items may not have a page frame + // in the walk above. Keep the warning visible after a native save/reopen. + if !out.iter().any(|issue| issue.kind == "unsupportedGraphicLayers") && d.has_graphic_layer_visibility_overrides() { + out.push(Issue { + severity: "warning", + kind: "unsupportedGraphicLayers", + message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(), + item: None, + page: None, + }); + } out } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 776dd5e82..92b46fa77 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -2017,6 +2017,7 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result { let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?; let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?; cell.graphic = Some(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (nw, nh), xf, diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 0009b4adb..a77cb1d7d 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -79,6 +79,7 @@ fn graphic_frame(d: &mut Document, sr: SpreadRef, r: Rect, asset: AssetId, px: ( let (nw, nh) = (px.0 as f64, px.1 as f64); let k = (r.width() / nw).max(r.height() / nh); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k), diff --git a/crates/engine/tests/graphic_layer_options.rs b/crates/engine/tests/graphic_layer_options.rs new file mode 100644 index 000000000..8699a4a66 --- /dev/null +++ b/crates/engine/tests/graphic_layer_options.rs @@ -0,0 +1,82 @@ +//! Per-placement artwork layer metadata must survive native saves and stay visibly unsupported. +use std::io::{Cursor, Read, Write}; + +use designcraft_doc::{Content, GRAPHIC_LAYER_RENDERING_WARNING}; +use designcraft_engine::{DocState, Session}; +use serde_json::json; + +fn fixture() -> Vec { + let xml = r#" + + + + + + + + + "#; + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + z.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(xml.as_bytes()).unwrap(); + z.finish().unwrap().into_inner() +} + +#[test] +fn graphic_layer_options_survive_native_reopen_and_report_unsupported_output() { + let imported = designcraft_idml::import_idml_report(&fixture(), &|_| None).unwrap(); + assert!(imported.warnings.iter().any(|w| w.contains(GRAPHIC_LAYER_RENDERING_WARNING))); + let doc = imported.document; + let options = |d: &designcraft_doc::Document| { + d.spreads[0] + .items + .iter() + .filter_map(|i| match &i.content { + Content::Graphic(g) => g.layer_options.clone(), + _ => None, + }) + .collect::>() + }; + let expected = options(&doc); + let saved = designcraft_format::save(&doc).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&saved)).unwrap(); + let mut meta = String::new(); + z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap(); + assert_eq!(serde_json::from_str::(&meta).unwrap()["version"], 4, "old readers must not silently drop the settings"); + let reopened = designcraft_format::load(&saved).unwrap(); + assert_eq!(options(&reopened), expected); + let mut s = Session::new(); + s.add_document(DocState::new(reopened, None)); + let preflight = s.execute("preflight.run", &json!({})).unwrap(); + assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers")); + let pdf = s.execute("file.exportPdf", &json!({})).unwrap(); + assert!(pdf["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING)); +} + +#[test] +fn graphic_layer_options_in_cells_require_the_new_native_reader() { + let mut doc = designcraft_idml::import_idml(&fixture()).unwrap(); + let graphic = doc.spreads[0] + .items + .iter() + .find_map(|i| match &i.content { + Content::Graphic(g) => Some(g.clone()), + _ => None, + }) + .unwrap(); + std::sync::Arc::make_mut(&mut doc.spreads[0]).items.clear(); + let layer = doc.default_layer(); + let (_, story) = doc + .add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()) + .unwrap(); + let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 20.0); + table.cells[0].graphic = Some(graphic); + doc.story_mut(story).unwrap().insert_table(0, table); + assert!(doc.has_graphic_layer_options()); + let reopened = designcraft_format::load(&designcraft_format::save(&doc).unwrap()).unwrap(); + assert!(reopened.has_graphic_layer_visibility_overrides()); + let mut s = Session::new(); + s.add_document(DocState::new(reopened, None)); + let preflight = s.execute("preflight.run", &json!({})).unwrap(); + assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers")); +} diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 348b9c80e..d0be6713a 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -1531,6 +1531,7 @@ mod tests { 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 { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, @@ -1939,6 +1940,7 @@ mod tests { let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0))); item.alt_text = alt.into(); item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, @@ -2055,6 +2057,7 @@ mod tests { let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0))); item.alt_text = "Cover \"A\" & B".into(); item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, diff --git a/crates/epub/tests/it/grouped_reading_order.rs b/crates/epub/tests/it/grouped_reading_order.rs index fe2223091..42811f61d 100644 --- a/crates/epub/tests/it/grouped_reading_order.rs +++ b/crates/epub/tests/it/grouped_reading_order.rs @@ -100,6 +100,7 @@ fn nested_and_item_rotations_are_combined_before_measuring_graphics() { let image = ItemId(doc.alloc()); let mut item = Item::new(image, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 10.0))); item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5e87c5408..4a3144f08 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -22,8 +22,8 @@ use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; /// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults; -/// version 3 adds rendering of retained EPS source assets. -pub const VERSION: u32 = 3; +/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer options. +pub const VERSION: u32 = 4; #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -87,7 +87,14 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en // Older readers would silently omit EPS artwork. Preview-only legacy assets and // documents without EPS stay at 2 so their existing readers keep working; writing 1 // would make a reader re-run the version 2 paragraph upgrade over current defaults. - let version = if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { VERSION } else { 2 }; + // Older readers ignore unknown Graphic fields and would silently drop layer choices. + let version = if doc.has_graphic_layer_options() { + 4 + } else if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { + 3 + } else { + 2 + }; let meta = json!({"format": "designcraft", "version": version, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; if let Some(png) = preview { @@ -404,6 +411,7 @@ mod tests { let id = ItemId(d.alloc()); let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0))); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (1.0, 1.0), xf: Affine::translate((crop, crop)), diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index c174c1889..fa25914ad 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -20,6 +20,14 @@ const TOC_STYLE_ID: &str = "TOCStyle/Table of Contents"; const TOC_STYLE_NAME: &str = "Table of Contents"; const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]"; +// Retained IDML attributes have unprefixed ASCII names. Native JSON can be +// edited independently: never let its map keys become XML syntax or Self IDs. +fn graphic_layer_attribute_name(key: &str) -> bool { + !key.is_empty() + && key != "Self" + && key.bytes().enumerate().all(|(i, b)| b.is_ascii_alphabetic() || b == b'_' || (i > 0 && (b.is_ascii_digit() || matches!(b, b'.' | b'-')))) +} + /// Export options. #[derive(Clone, Debug)] pub struct ExportOptions { @@ -2061,6 +2069,24 @@ impl<'a> Ex<'a> { if let Some(c) = &g.clip { el.push(clipping_settings(c)); } + if let Some(options) = &g.layer_options { + let mut option = El::new("GraphicLayerOption"); + for (key, value) in &options.attributes { + if graphic_layer_attribute_name(key) { + option.set(key, value); + } + } + for attributes in &options.layers { + let mut layer = El::new("GraphicLayer").attr("Self", self.fresh()); + for (key, value) in attributes { + if graphic_layer_attribute_name(key) { + layer.set(key, value); + } + } + option.push(layer); + } + el.push(option); + } let uri = match &asset.link { Some(l) => path_to_uri(l), None => format!("file:{}", asset.name), diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index fed9f49b9..9e1c01520 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -3043,6 +3043,13 @@ impl<'r> Importer<'r> { } fn graphic(&mut self, g: &El) -> Content { + let layer_options = g.find("GraphicLayerOption").map(|options| { + let attrs = |e: &El| e.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect(); + designcraft_doc::GraphicLayerOptions { attributes: attrs(options), layers: options.find_all("GraphicLayer").map(attrs).collect() } + }); + if layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) { + self.warnings.push(format!("graphic {}: {}", g.get("Self").unwrap_or("(unnamed)"), designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING)); + } let gxf = parse_affine(g.get("ItemTransform")); let (l, t, r, b) = match g.prop_el("GraphicBounds") { Some(gb) => { @@ -3059,6 +3066,7 @@ impl<'r> Importer<'r> { let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path); let graphic = |asset, size| { Content::Graphic(designcraft_doc::Graphic { + layer_options: layer_options.clone(), asset, size, xf: gxf * Affine::translate((l, t)), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index b72ba1c86..51c362dce 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4453,6 +4453,7 @@ fn exports_graphic_in_table_cell() { }); let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0); t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic { + layer_options: None, asset: id, size: (24.0, 24.0), xf: designcraft_geom::Affine::IDENTITY, diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index 23ec31f34..9372c3adf 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -70,6 +70,77 @@ fn package(frames: &[String]) -> Vec { ]) } +#[test] +fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() { + let option = |n: usize, visible: &str| { + format!( + r#""# + ) + }; + let frames = ["false", "true"] + .into_iter() + .enumerate() + .map(|(i, visible)| { + let n = i + 1; + frame(n, i as f64 * 100.0, 0.0, &image(n, PHOTO, None).replace("", &format!("{}", option(n, visible)))) + }) + .collect::>(); + let reads = Cell::new(0); + let loaded = import_idml_report_with_previews( + &package(&frames), + &|_| { + reads.set(reads.get() + 1); + Some(vec![42; 16]) + }, + &Default::default(), + ) + .unwrap(); + assert_eq!(reads.get(), 1); + assert_eq!(loaded.document.assets.len(), 1, "layer choices belong to placements, not shared image bytes"); + let out = export_idml(&loaded.document); + let spreads = zip_text(&out, "Spreads/"); + assert_eq!(spreads.matches(" g.layer_options.clone(), + _ => None, + }) + .collect::>() + }; + assert_eq!(options(&loaded.document), options(&reopened)); + let twice = zip_text(&export_idml(&reopened), "Spreads/"); + assert_eq!(twice.matches(" Vec<(AssetId, [f64; 4], designcraft_geom::Affine)> { let mut out: Vec<(f64, AssetId, [f64; 4], designcraft_geom::Affine)> = d.spreads[0] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 5d3ad0644..7df892abf 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1381,6 +1381,9 @@ impl Exporter<'_> { /// Draw a placed graphic, inside its clipping path when it has one. pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) { + if g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) { + self.warn(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING); + } match g.clip_region().map(|(region, even_odd)| (to_path(®ion), even_odd)) { Some((Some(clip), even_odd)) => { s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero }); @@ -2175,6 +2178,7 @@ mod eps_tests { designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)), ); item.content = Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (2.0, 1.0), xf: Affine::translate((72.0, 72.0)) * Affine::scale(100.0), diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 11d30602b..5566353fd 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -496,6 +496,7 @@ mod tests { let r = Rect::new(at.0, at.1, at.0 + 100.0, at.1 + 50.0); let mut it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size, xf: Affine::translate(at) * Affine::scale(100.0 / size.0), diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 39f472ffc..663ef9169 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -257,6 +257,7 @@ fn place(d: &mut Document, data: Vec, px: (u32, u32)) { let id = ItemId(d.alloc()); let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0))); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset, size: (px.0 as f64, px.1 as f64), xf: designcraft_geom::Affine::translate((72.0, 72.0)) * designcraft_geom::Affine::scale(50.0), diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs index 2442c33a0..a35fc464e 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -30,6 +30,7 @@ fn place_named(d: &mut Document, data: Vec, x: f64, name: &str, mime: &str, let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 36.0, x + 20.0, 56.0))); item.stroke.weight = 0.0; item.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (20.0, 20.0), xf: Affine::translate((x, 36.0)), diff --git a/crates/pptx/src/tests.rs b/crates/pptx/src/tests.rs index ba97a67b8..253c5d260 100644 --- a/crates/pptx/src/tests.rs +++ b/crates/pptx/src/tests.rs @@ -212,6 +212,7 @@ fn pictures_are_cropped_to_their_frames() { it.fill = Fill::none(); // 200 × 100 image at 1:1, its left quarter cut off by the frame. it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (200.0, 100.0), xf: Affine::translate((50.0, 100.0)), diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 522e245cb..d9b527a5e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1984,6 +1984,7 @@ mod tests { let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (100.0, 100.0), xf: Affine::translate((100.0, 100.0)), @@ -2038,6 +2039,7 @@ mod tests { let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: aid, size: (100.0, 100.0), xf: Affine::translate((100.0, 100.0)), diff --git a/crates/render/tests/it/colorize.rs b/crates/render/tests/it/colorize.rs index 883b36b8e..5bf87a54d 100644 --- a/crates/render/tests/it/colorize.rs +++ b/crates/render/tests/it/colorize.rs @@ -51,6 +51,7 @@ fn shown(file: Vec, fill: Fill) -> Vec<[u8; 4]> { it.fill = Fill::swatch(CYAN); it.stroke = Stroke::default(); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (150.0, 50.0), xf: Affine::translate((100.0, 100.0)), diff --git a/crates/render/tests/it/placed_pdf.rs b/crates/render/tests/it/placed_pdf.rs index 118453067..deb699e11 100644 --- a/crates/render/tests/it/placed_pdf.rs +++ b/crates/render/tests/it/placed_pdf.rs @@ -78,6 +78,7 @@ fn placed(size: f64, pdf: Vec) -> Rendered { let id = ItemId(d.alloc()); let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size))); item.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (size, size), xf: Affine::IDENTITY, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..34bc40fc6 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -27,6 +27,7 @@ fn color_theme_tool_requests_a_palette_without_editing_the_image() { let mut d = Document::new(&NewDocument::default()); let id = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); d.item_mut(id).unwrap().content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1108,6 +1109,7 @@ fn graphic_frame(doc: &mut Document) -> ItemId { let id = ItemId(doc.alloc()); let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (200.0, 100.0), xf: designcraft_geom::Affine::translate((50.0, 100.0)), @@ -1502,6 +1504,7 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { let photo = ItemId(d.alloc()); let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::translate((0.0, 0.0)), @@ -1855,6 +1858,7 @@ fn eyedropper_ignores_inactive_states_of_a_multi_state_object() { let raster = { let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::translate((0.0, 0.0)), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 0422936e3..58cf42b24 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2943,6 +2943,7 @@ mod tests { }), ); doc.item_mut(id).unwrap().content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (72.0, 72.0), xf: designcraft_geom::Affine::IDENTITY, diff --git a/docs/gaps.md b/docs/gaps.md index 6723fe1e7..c9221039b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -202,6 +202,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 20–35 h. ### 19. Placed graphics depth (FF) +- **Have:** per-placement IDML graphic-layer settings survive IDML/native round trips; native format 4 guards older readers. Unsupported layer visibility is reported by import, preflight and PDF export. See [graphic-layer-options.md](graphic-layer-options.md). +- **Still missing:** applying the retained layer visibility to the rendered/PDF appearance; layer-comp choices and the layer-options UI. Metadata preservation does not establish visual fidelity. - **Safety:** PDF visibility variants preserve catalog/configuration entries and nonzero-generation references, redefining the existing catalog rather than choosing an arbitrary object number. Encrypted, hybrid-reference and over-budget rewrites are rejected; no print-fidelity claim. diff --git a/docs/graphic-layer-options.md b/docs/graphic-layer-options.md new file mode 100644 index 000000000..953995839 --- /dev/null +++ b/docs/graphic-layer-options.md @@ -0,0 +1,40 @@ +# Placed graphic layer options + +IDML `GraphicLayerOption` / `GraphicLayer` settings are retained on each +`Graphic.layer_options`, not on the shared asset. Two placements of one linked +file can therefore keep different layer choices while sharing image bytes. +Scalar option/layer attributes, layer order, layer IDs/names, original/current +visibility, link-update policy and view/export/print flags survive import, +native save/reopen and IDML export. XML Self IDs are regenerated. Native-edited +attribute maps cannot inject XML syntax or override those Self IDs. + +This is preservation, **not layer-aware rendering**. The current renderer and +PDF exporter use the asset's composite/default appearance and do not apply +these per-placement choices. Changed visibility and explicit view/export/print +states produce an import warning, a persistent `unsupportedGraphicLayers` +preflight issue, and a PDF export warning. Nested graphic cells, anchored +objects and parent items remain detectable after native reopening. + +Native files containing these settings require format version 4. Earlier +readers reject that version instead of loading and silently discarding the new +field. Documents without layer settings retain their existing version 2/3 +requirements. Older graphics without `layerOptions` still deserialize with +`None`, and their JSON representation is unchanged. + +Synthetic regressions cover independent overrides on two placements sharing +one asset, metadata round trips, native reader requirements, nested cells, +preflight/PDF warnings and invalid native attribute names. Private originals +and artwork are not repository fixtures; any collection results are local +metadata checks, not an independent reference-rendering or fidelity oracle. + +Local collection check (2026-10-23): a five-document pilot passed before +expansion to 205 previously converted documents. All 205 completed IDML → +native save/reopen → IDML; all 1,113 layer records across 68 documents matched +their input scalar attributes and order, ignoring regenerated Self IDs. +Persistent preflight warnings appeared in the 57 documents with overrides. +The private source files, layer names, document paths and per-file logs remain +outside this repository. Original INDD files were not opened by this check. + +Remaining work: apply visibility to PSD layers/PDF optional content, implement +layer-comp choices and a layer-options UI, and validate appearance against +verified same-save reference PDFs. Gap 19 remains open. From 3d362d0bdfda036ac43d3e6b51b18d6aff03c386 Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 10:42:21 +0100 Subject: [PATCH 24/71] M6: preserve nested graphic layers and retain export diagnostics --- crates/doc/src/assets.rs | 6 +++ crates/doc/src/item.rs | 3 ++ crates/engine/src/cmd/export.rs | 4 ++ crates/engine/src/cmd/preflight.rs | 2 +- crates/engine/tests/graphic_layer_options.rs | 21 +++++++++-- crates/idml/src/export.rs | 26 ++++++++++++- crates/idml/src/import.rs | 15 +++++++- crates/idml/src/tests/assets.rs | 39 +++++++++++++++++++- docs/graphic-layer-options.md | 15 ++++++-- 9 files changed, 119 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index ed7679ee5..872d15ba8 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -39,6 +39,12 @@ impl Document { || self.stories.values().any(|story| story_layer_options(story, 0, false)) } + /// Placements outside the ordinary document-spread frame walk used by preflight. + pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool { + self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) + || self.stories.values().any(|story| story_layer_options(story, 0, true)) + } + /// Includes parent items, anchored objects, graphic cells and nested notes. pub fn has_graphic_layer_visibility_overrides(&self) -> bool { self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 9c4169fe4..586358f1d 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -604,6 +604,9 @@ pub enum Fitting { pub struct GraphicLayerOptions { pub attributes: std::collections::BTreeMap, pub layers: Vec>, + /// Parent indices in pre-order; absent entries are top-level layers (older native files). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parents: Vec>, } pub const GRAPHIC_LAYER_RENDERING_WARNING: &str = diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 3797a9bf6..c2ca1e317 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -745,6 +745,10 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { let doc: &designcraft_doc::Document = &boxed; let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; let mut r = r; + // Flattening and effect rasterization replace placements, so inspect the source too. + if st.doc.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) { + r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into()); + } // Transparency Blend Space: the page group of pages with transparency. let spreads = designcraft_pdf::sheet_spreads(doc, &opts); let blended: Vec = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect(); diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index c69050ace..a0c1a6da1 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -320,7 +320,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { } // Nested graphic cells and anchored/parent items may not have a page frame // in the walk above. Keep the warning visible after a native save/reopen. - if !out.iter().any(|issue| issue.kind == "unsupportedGraphicLayers") && d.has_graphic_layer_visibility_overrides() { + if d.has_unwalked_graphic_layer_visibility_overrides() { out.push(Issue { severity: "warning", kind: "unsupportedGraphicLayers", diff --git a/crates/engine/tests/graphic_layer_options.rs b/crates/engine/tests/graphic_layer_options.rs index 8699a4a66..117517002 100644 --- a/crates/engine/tests/graphic_layer_options.rs +++ b/crates/engine/tests/graphic_layer_options.rs @@ -13,7 +13,7 @@ fn fixture() -> Vec { - + "#; let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); @@ -64,7 +64,7 @@ fn graphic_layer_options_in_cells_require_the_new_native_reader() { _ => None, }) .unwrap(); - std::sync::Arc::make_mut(&mut doc.spreads[0]).items.clear(); + // Keep the original spread graphic too: its warning must not hide the cell warning. let layer = doc.default_layer(); let (_, story) = doc .add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()) @@ -78,5 +78,20 @@ fn graphic_layer_options_in_cells_require_the_new_native_reader() { let mut s = Session::new(); s.add_document(DocState::new(reopened, None)); let preflight = s.execute("preflight.run", &json!({})).unwrap(); - assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers")); + let warnings: Vec<_> = preflight["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "unsupportedGraphicLayers").collect(); + assert!(warnings.iter().any(|i| i["item"].is_null()), "the cell needs an independent document-level warning"); + assert!(warnings.iter().any(|i| !i["item"].is_null()), "the spread retains its item warning"); +} + +#[test] +fn pdf_layer_warning_survives_soft_effects_and_flattening() { + for flatten in [false, true] { + let doc = designcraft_idml::import_idml(&fixture()).unwrap(); + let id = doc.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().id.0; + let mut session = Session::new(); + session.add_document(DocState::new(doc, None)); + session.execute("object.feather", &json!({"ids":[id], "width":2})).unwrap(); + let out = session.execute("file.exportPdf", &json!({"flatten":flatten})).unwrap(); + assert!(out["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING), "{out}"); + } } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index fa25914ad..0e338c4b6 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -25,6 +25,7 @@ const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]"; fn graphic_layer_attribute_name(key: &str) -> bool { !key.is_empty() && key != "Self" + && key != "xmlns" && key.bytes().enumerate().all(|(i, b)| b.is_ascii_alphabetic() || b == b'_' || (i > 0 && (b.is_ascii_digit() || matches!(b, b'.' | b'-')))) } @@ -2076,13 +2077,36 @@ impl<'a> Ex<'a> { option.set(key, value); } } - for attributes in &options.layers { + // Parent indices must precede their children. Ignore invalid parents and bound DOM depth + // even when a native file has been edited independently of the IDML importer. + let mut depths = Vec::with_capacity(options.layers.len()); + let mut parents = Vec::with_capacity(options.layers.len()); + for i in 0..options.layers.len() { + let parent = options.parents.get(i).copied().flatten().filter(|p| *p < i && depths.get(*p).is_some_and(|d| *d < 240)); + depths.push(parent.and_then(|p| depths.get(p)).copied().unwrap_or(0) + 1); + parents.push(parent); + } + let mut children: Vec> = vec![Vec::new(); options.layers.len()]; + let mut roots = Vec::new(); + for (i, attributes) in options.layers.iter().enumerate().rev() { let mut layer = El::new("GraphicLayer").attr("Self", self.fresh()); for (key, value) in attributes { if graphic_layer_attribute_name(key) { layer.set(key, value); } } + if let Some(nested) = children.get_mut(i) { + for child in std::mem::take(nested).into_iter().rev() { + layer.push(child); + } + } + if let Some(nested) = parents.get(i).copied().flatten().and_then(|parent| children.get_mut(parent)) { + nested.push(layer); + } else { + roots.push(layer); + } + } + for layer in roots.into_iter().rev() { option.push(layer); } el.push(option); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 9e1c01520..3db03576f 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -3045,7 +3045,20 @@ impl<'r> Importer<'r> { fn graphic(&mut self, g: &El) -> Content { let layer_options = g.find("GraphicLayerOption").map(|options| { let attrs = |e: &El| e.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect(); - designcraft_doc::GraphicLayerOptions { attributes: attrs(options), layers: options.find_all("GraphicLayer").map(attrs).collect() } + let mut out = designcraft_doc::GraphicLayerOptions { attributes: attrs(options), ..Default::default() }; + let mut pending: Vec<_> = options.find_all("GraphicLayer").map(|e| (e, None)).collect(); + pending.reverse(); + while let Some((layer, parent)) = pending.pop() { + let index = out.layers.len(); + out.layers.push(attrs(layer)); + out.parents.push(parent); + let children: Vec<_> = layer.find_all("GraphicLayer").collect(); + pending.extend(children.into_iter().rev().map(|e| (e, Some(index)))); + } + if out.parents.iter().all(Option::is_none) { + out.parents.clear(); + } + out }); if layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) { self.warnings.push(format!("graphic {}: {}", g.get("Self").unwrap_or("(unnamed)"), designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING)); diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index 9372c3adf..71c01ff0b 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -70,6 +70,23 @@ fn package(frames: &[String]) -> Vec { ]) } +#[test] +fn nested_graphic_layers_preserve_hierarchy_on_roundtrip() { + let option = r#" + + + "#; + let image = image(1, PHOTO, None).replace("", &format!("{option}")); + let d = import_idml(&package(&[frame(1, 0.0, 0.0, &image)])).unwrap(); + let options = |d: &Document| d.spreads[0].items[0].graphic().unwrap().layer_options.clone().unwrap(); + let expected = options(&d); + assert_eq!(expected.layers.len(), 3); + assert_eq!(expected.parents, [None, Some(0), None]); + assert!(expected.has_visibility_overrides()); + let reopened = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(options(&reopened), expected); +} + #[test] fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() { let option = |n: usize, visible: &str| { @@ -121,6 +138,23 @@ fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() { assert_eq!(twice.matches(" Date: Fri, 23 Oct 2026 19:23:13 +0100 Subject: [PATCH 25/71] M6: retain layer order when native parents reopen closed subtrees --- ROADMAP.md | 2 ++ crates/idml/src/export.rs | 18 ++++++++++++------ crates/idml/src/tests/assets.rs | 17 +++++++++++++++++ docs/graphic-layer-options.md | 2 +- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 01c748d33..f361831d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,8 @@ 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. +- **2026-10-23** · IDML graphic-layer export rejects parents in already closed subtrees, + preserving native-edited layer order through round trips. Synthetic regression added. - **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites leave document/undo state intact. Null trailer entries remain unencrypted, and unsupported all-content crops retain the whole page rather than silently clipping hidden layers. diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0e338c4b6..77381a215 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2077,14 +2077,20 @@ impl<'a> Ex<'a> { option.set(key, value); } } - // Parent indices must precede their children. Ignore invalid parents and bound DOM depth - // even when a native file has been edited independently of the IDML importer. - let mut depths = Vec::with_capacity(options.layers.len()); + // Only an ancestor whose subtree is still open can parent the next layer. + // Reopening a closed subtree would reorder native-edited lists on roundtrip. + let mut open = Vec::new(); let mut parents = Vec::with_capacity(options.layers.len()); for i in 0..options.layers.len() { - let parent = options.parents.get(i).copied().flatten().filter(|p| *p < i && depths.get(*p).is_some_and(|d| *d < 240)); - depths.push(parent.and_then(|p| depths.get(p)).copied().unwrap_or(0) + 1); - parents.push(parent); + let parent = options.parents.get(i).copied().flatten(); + if let Some(at) = parent.and_then(|p| open.iter().position(|id| *id == p)).filter(|at| *at < 239) { + open.truncate(at + 1); + parents.push(parent); + } else { + open.clear(); + parents.push(None); + } + open.push(i); } let mut children: Vec> = vec![Vec::new(); options.layers.len()]; let mut roots = Vec::new(); diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index 71c01ff0b..48875c256 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -155,6 +155,23 @@ fn malformed_native_graphic_layer_parents_cannot_cycle_or_overflow_xml_depth() { assert_eq!(reopened.spreads[0].items[0].graphic().unwrap().layer_options.as_ref().unwrap().layers.len(), 300); } +#[test] +fn closed_graphic_layer_subtrees_cannot_reorder_native_layers() { + let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap(); + let Content::Graphic(g) = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.spreads[0]).items[0]).content else { + panic!("graphic") + }; + g.layer_options = Some(designcraft_doc::GraphicLayerOptions { + layers: ["A", "B", "C", "D"].into_iter().map(|name| std::collections::BTreeMap::from([("Name".into(), name.into())])).collect(), + parents: vec![None, None, Some(0), Some(2)], + ..Default::default() + }); + let reopened = import_idml(&export_idml(&d)).unwrap(); + let options = reopened.spreads[0].items[0].graphic().unwrap().layer_options.as_ref().unwrap(); + assert_eq!(options.layers.iter().map(|a| a["Name"].as_str()).collect::>(), ["A", "B", "C", "D"]); + assert_eq!(options.parents, [None, None, None, Some(2)]); +} + #[test] fn graphic_layer_attributes_from_native_data_cannot_inject_xml_or_self_ids() { let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap(); diff --git a/docs/graphic-layer-options.md b/docs/graphic-layer-options.md index 7668f42f3..53a527991 100644 --- a/docs/graphic-layer-options.md +++ b/docs/graphic-layer-options.md @@ -8,7 +8,7 @@ visibility, link-update policy and view/export/print flags survive import, native save/reopen and IDML export. XML Self IDs are regenerated. Native-edited attribute maps cannot inject XML syntax, declare namespaces or override those Self IDs. Parent indices are optional in native files; missing entries retain the older -flat representation. Invalid/cyclic parent indices and over-budget nesting +flat representation. Invalid/cyclic parent indices, parents in closed subtrees and over-budget nesting are emitted as top-level layers, retaining their scalar metadata without unbounded XML recursion. From 2633fe8b5dded65f7faeaf666de93b7668fcaa14 Mon Sep 17 00:00:00 2001 From: Andee Collard Date: Sat, 24 Oct 2026 13:15:46 +0100 Subject: [PATCH 26/71] Format: choose the native version in one place; share v4 with #1671 skylartaylor's review: #1647 and #1671 each introduced native format 4 for a different capability, so a reader that understood only one could accept a file carrying the other and silently drop its settings. No release has shipped v4 (main and v0.12.0 still write at most 3), so v4 is defined as one capability covering both retained placed-graphic layer options and explicit all-on PDF layer choices; #1671 is stacked on this branch. Move the version choice into required_version so every new capability is decided in one place, document the shared v4 contract on VERSION, and test that plain, EPS-only and layer-option documents keep 2, 3 and 4 respectively. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/lib.rs | 49 ++++++++++++++++++++++++++--------- docs/graphic-layer-options.md | 5 ++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4a3144f08..d119c3538 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -22,7 +22,10 @@ use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; /// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults; -/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer options. +/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer +/// options. Version 4 is one capability shared with explicit all-on placed-PDF layer choices +/// (storytold/designcraft#1671): it must not be released until a reader understands both, and any +/// later capability needs version 5. [`required_version`] is the only place that picks a version. pub const VERSION: u32 = 4; #[derive(Debug, thiserror::Error)] @@ -67,6 +70,19 @@ pub fn save_with_preview(doc: &Document, preview: Option<&[u8]>) -> Result u32 { + // Older readers ignore unknown Graphic fields and would silently drop layer choices. + if doc.has_graphic_layer_options() { + return 4; + } + // Older readers would silently omit EPS artwork. Preview-only legacy assets and documents + // without EPS stay at 2 so their existing readers keep working; writing 1 would make a + // reader re-run the version 2 paragraph upgrade over current defaults. + if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { 3 } else { 2 } +} + fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_entry: usize) -> Result, FormatError> { // JSON writes infinity and NaN as null, which no load accepts: the file would not open. if let Some(id) = doc.non_finite_item() { @@ -84,17 +100,7 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en z.start_file("mimetype", stored).map_err(io)?; z.write_all(MIME.as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; z.start_file("meta.json", deflate).map_err(io)?; - // Older readers would silently omit EPS artwork. Preview-only legacy assets and - // documents without EPS stay at 2 so their existing readers keep working; writing 1 - // would make a reader re-run the version 2 paragraph upgrade over current defaults. - // Older readers ignore unknown Graphic fields and would silently drop layer choices. - let version = if doc.has_graphic_layer_options() { - 4 - } else if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { - 3 - } else { - 2 - }; + let version = required_version(doc); let meta = json!({"format": "designcraft", "version": version, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; if let Some(png) = preview { @@ -481,6 +487,25 @@ mod tests { assert_eq!(version(&d), 2, "legacy EPS previews stay compatible"); } + #[test] + fn one_place_picks_the_oldest_reader_that_keeps_the_document() { + use designcraft_doc::{Content, GraphicLayerOptions}; + let mut d = Document::new(&NewDocument::default()); + assert_eq!(required_version(&d), 2, "plain documents keep the version 2 reader"); + let aid = AssetId(d.alloc()); + let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec(); + d.assets.insert( + aid, + Arc::new(Asset { id: aid, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(eps), ..Default::default() }), + ); + let id = place(&mut d, aid, 0.0); + assert_eq!(required_version(&d), 3, "EPS sources alone keep the version 3 reader"); + let Some(Content::Graphic(g)) = d.item_mut(id).map(|it| &mut it.content) else { panic!("placed graphic") }; + g.layer_options = Some(GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() }); + assert_eq!(required_version(&d), 4, "retained graphic layer options need version 4 even with EPS"); + assert_eq!(required_version(&load(&save(&d).unwrap()).unwrap()), 4); + } + #[test] fn roundtrip_with_assets() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); diff --git a/docs/graphic-layer-options.md b/docs/graphic-layer-options.md index 53a527991..f38d82d34 100644 --- a/docs/graphic-layer-options.md +++ b/docs/graphic-layer-options.md @@ -27,6 +27,11 @@ field. Documents without layer settings retain their existing version 2/3 requirements. Older graphics without `layerOptions` still deserialize with `None`, and their JSON representation is unchanged. +Version 4 is a single reader capability shared with explicit all-on placed-PDF +layer choices (#1671): a version 4 reader must understand both, so the two +changes ship in the same release. One function in the format crate, +`required_version`, chooses the version for every save. + Synthetic regressions cover independent overrides on two placements sharing one asset, metadata round trips, native reader requirements, nested cells, nested layer hierarchy, preflight/PDF warnings after rasterization and flattening, From 8d295b101e6a0ae2fd537d853af1b1d652fcb50f Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 17:56:49 +0100 Subject: [PATCH 27/71] PDF: distinguish all-on layer choices from file defaults --- ROADMAP.md | 3 + crates/doc/src/assets.rs | 20 +++++++ crates/doc/src/item.rs | 13 +++++ crates/engine/src/cmd/export.rs | 4 +- crates/engine/src/cmd/interchange.rs | 45 ++++++++++++++- crates/engine/src/cmd/pdflayers.rs | 84 ++++++++++++++++++++++++++-- crates/format/src/lib.rs | 44 +++++++++++++-- crates/render/src/lib.rs | 43 ++++++++------ crates/ui-egui/src/dialogs.rs | 9 ++- crates/ui-egui/src/menus.rs | 5 +- docs/gaps.md | 3 + docs/pdf-layer-visibility.md | 9 +++ 12 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 docs/pdf-layer-visibility.md diff --git a/ROADMAP.md b/ROADMAP.md index f361831d3..0f621b7ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,9 @@ 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. +- **2026-10-23** · Placed PDF layers can explicitly all be shown, separately from Use File Defaults. + Legacy commands keep their reset behavior; native saves require a capable reader only for + the new all-on state, and PDF/embedded IDML exports preserve the choice. No readiness percentage change. - **2026-10-23** · IDML graphic-layer export rejects parents in already closed subtrees, preserving native-edited layer order through round trips. Synthetic regression added. - **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 872d15ba8..c526c98ba 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -31,6 +31,17 @@ fn story_layer_options(st: &Story, depth: usize, overrides_only: bool) -> bool { || st.notes.iter().chain(&st.endnotes).any(|note| story_layer_options(¬e.text, depth + 1, overrides_only)) } +fn item_any(it: &Item, depth: usize, f: &impl Fn(&Item) -> bool) -> bool { + depth > MAX_DEPTH || f(it) || it.children().iter().any(|child| item_any(child, depth + 1, f)) +} + +fn story_any(st: &Story, depth: usize, f: &impl Fn(&Item) -> bool) -> bool { + depth > MAX_DEPTH + || st.objects.iter().any(|object| item_any(&object.item, depth + 1, f)) + || st.tables.values().flat_map(|table| &table.cells).any(|cell| story_any(&cell.text, depth + 1, f)) + || st.notes.iter().chain(&st.endnotes).any(|note| story_any(¬e.text, depth + 1, f)) +} + impl Document { /// Whether saving needs a reader that retains per-placement graphic layer settings. /// Exceeding the traversal budget conservatively requires the newer format. @@ -39,6 +50,15 @@ impl Document { || self.stories.values().any(|story| story_layer_options(story, 0, false)) } + /// Whether any placed PDF, on document or parent spreads, in groups or anchored, explicitly + /// shows every layer (an override with an empty hidden list). Older readers would take that + /// for "use the file's defaults". Exceeding the traversal budget conservatively says yes. + pub fn has_explicit_all_on_pdf_layers(&self) -> bool { + let all_on = |it: &Item| it.pdf_layer_override && it.pdf_hidden_layers.is_empty(); + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &all_on)) + || self.stories.values().any(|story| story_any(story, 0, &all_on)) + } + /// Placements outside the ordinary document-spread frame walk used by preflight. pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool { self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 586358f1d..1db88edbb 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1398,6 +1398,10 @@ pub struct Item { /// Object › Object Layer Options: layers of a placed PDF that are hidden. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pdf_hidden_layers: Vec, + /// Explicit placed-PDF visibility, including an all-on override with no hidden names. + /// False with an empty hidden list retains the file's own settings (legacy behavior). + #[serde(default, skip_serializing_if = "is_false")] + pub pdf_layer_override: bool, /// Window › Interactive › Media: options of a placed video or sound. #[serde(default, skip_serializing_if = "Option::is_none")] pub media: Option, @@ -1430,7 +1434,15 @@ fn one() -> f32 { 1.0 } +fn is_false(value: &bool) -> bool { + !*value +} + impl Item { + pub fn has_pdf_layer_override(&self) -> bool { + self.pdf_layer_override || !self.pdf_hidden_layers.is_empty() + } + pub fn new(id: ItemId, layer: LayerId, shape: Shape, path: PathData) -> Self { Item { id, @@ -1457,6 +1469,7 @@ impl Item { xml_tag: String::new(), button: None, pdf_hidden_layers: Vec::new(), + pdf_layer_override: false, live_caption: None, form_field: None, export_options: ExportOptions::default(), diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index c2ca1e317..1d1e19c24 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -673,7 +673,7 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I /// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { let ids: Vec = - d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect(); + d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| it.has_pdf_layer_override() && it.graphic().is_some())).collect(); if ids.is_empty() { return Ok((None, 0)); } @@ -690,7 +690,7 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option Vec { fn export_idml(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let opts = designcraft_idml::ExportOptions { embed_images: p.get("embedImages").and_then(Value::as_bool).unwrap_or(true) }; - let bytes = designcraft_idml::export_idml_with(&st.doc, &opts); + let layer_doc = with_pdf_layer_variants(&st.doc, opts.embed_images)?; + let bytes = designcraft_idml::export_idml_with(layer_doc.as_ref().unwrap_or(&st.doc), &opts); match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] @@ -39,6 +40,48 @@ fn export_idml(s: &mut Session, p: &Value) -> Result { } } +/// Each placement gets its own embedded variant so two frames sharing a PDF can +/// choose different layers. The live document and original linked bytes stay untouched. +fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result> { + let ids: Vec<_> = doc + .all_items() + .into_iter() + .filter(|id| { + doc.item(*id).is_some_and(|it| { + it.has_pdf_layer_override() + && it.graphic().and_then(|g| doc.assets.get(&g.asset)).is_some_and(|a| designcraft_render::is_pdf(&a.data)) + }) + }) + .collect(); + if ids.is_empty() { + return Ok(None); + } + if !embed { + return Err(EngineError::Other("PDF layer choices require embedded images in IDML export".into())); + } + let mut out = doc.clone(); + for id in ids { + let Some(it) = doc.item(id) else { continue }; + let Some(asset) = it.graphic().and_then(|g| doc.assets.get(&g.asset)) else { continue }; + let bytes = designcraft_render::pdf_hide_layers(&asset.data, &it.pdf_hidden_layers) + .ok_or_else(|| EngineError::Other(format!("{}: PDF layer choices could not be applied", asset.name)))?; + let mut variant = (**asset).clone(); + variant.id = designcraft_doc::AssetId(out.alloc()); + variant.data = std::sync::Arc::new(bytes); + variant.link = None; + let aid = variant.id; + out.assets.insert(aid, std::sync::Arc::new(variant)); + if let Some(it) = out.item_mut(id) { + if let designcraft_doc::Content::Graphic(g) = &mut it.content { + g.asset = aid; + } + it.pdf_layer_override = false; + it.pdf_hidden_layers.clear(); + } + } + Ok(Some(out)) +} + /// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`] from /// beside `package` (the package's file, if it has one), so the automation read root applies to /// the paths the package names, and its PDF crops resolved. diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index 937fe81c0..8d339dc9d 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -17,11 +17,13 @@ pub fn specs() -> Vec { cmd!(query "object.pdfLayers", "Object Layers", [], None, "{id?} → [{name, visible, default}] — the layers of the selected placed PDF", has_selection, |s, p| { let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.first().copied())).ok_or_else(|| bad("object.pdfLayers", "no selection"))?; let data = placed_pdf(s, id).ok_or_else(|| bad("object.pdfLayers", "not a placed PDF"))?; - let hidden = s.doc()?.doc.item(id).map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default(); + let item = s.doc()?.doc.item(id); + let hidden = item.map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default(); + let overridden = item.is_some_and(|i| i.has_pdf_layer_override()); Ok(Value::Array( designcraft_render::pdf_layers(&data) .into_iter() - .map(|(name, on)| json!({"visible": if hidden.is_empty() { on } else { !hidden.contains(&name) }, "name": name, "default": on})) + .map(|(name, on)| json!({"visible": if overridden { !hidden.contains(&name) } else { on }, "name": name, "default": on, "overridden": overridden})) .collect(), )) }), @@ -30,16 +32,23 @@ pub fn specs() -> Vec { "Object Layer Options…", ["Object"], None, - "{ids?, hidden: [layer name]} — layers of placed PDFs to hide ([] = the file's own settings)", + "{ids?, hidden: [layer name], useDefaults?: bool} — hide layers; useDefaults:false makes [] explicitly all on; legacy [] restores file defaults", has_selection, |s, p| { - let ids = targets(s, p)?; + let ids: Vec<_> = targets(s, p)?.into_iter().filter(|id| placed_pdf(s, *id).is_some()).collect(); let hidden: Vec = p .get("hidden") .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) .unwrap_or_default(); - if ids.iter().all(|id| placed_pdf(s, *id).is_none()) { + let use_defaults = match p.get("useDefaults") { + None => hidden.is_empty(), + Some(v) => v.as_bool().ok_or_else(|| bad("object.layerOptions", "useDefaults must be a boolean"))?, + }; + if use_defaults && !hidden.is_empty() { + return Err(bad("object.layerOptions", "useDefaults cannot be combined with hidden layers")); + } + if ids.is_empty() { return Err(bad("object.layerOptions", "select a placed PDF")); } // Validate every selected PDF before editing any item or recording undo. @@ -62,9 +71,10 @@ pub fn specs() -> Vec { for id in &ids { if let Some(it) = d.item_mut(*id) { it.pdf_hidden_layers = hidden.clone(); + it.pdf_layer_override = !use_defaults; } } - Ok(json!({"hidden": hidden})) + Ok(json!({"hidden": hidden, "useDefaults": use_defaults})) }) } ), @@ -378,4 +388,66 @@ pub(crate) mod tests { let r = s.execute("file.exportPdf", &json!({})).unwrap(); assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("layer")), "{r}"); } + #[test] + fn placed_pdf_all_on_is_distinct_from_defaults_and_survives_exports() { + let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name":"off.pdf", "x":100,"y":100,"width":100})) + .unwrap()["id"] + .clone(); + let visible = |s: &mut Session| s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"].as_bool().unwrap(); + let pixel = |s: &Session| { + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + rr.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(175, 150) + }; + assert!(!visible(&mut s)); + assert!(pixel(&s)[1] > 200, "red hidden by default"); + s.execute("object.layerOptions", &json!({"ids":[id], "hidden":[], "useDefaults":false})).unwrap(); + assert!(visible(&mut s)); + assert!(pixel(&s)[0] > 200 && pixel(&s)[1] < 40, "all-on renders red"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!visible(&mut s)); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(visible(&mut s)); + let bytes = designcraft_format::save(&s.doc().unwrap().doc).unwrap(); + let roundtrip = designcraft_format::load(&bytes).unwrap(); + let item = roundtrip.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap(); + assert!(item.pdf_layer_override && item.pdf_hidden_layers.is_empty()); + let exported = s.execute("file.exportPdf", &json!({})).unwrap(); + let bytes = super::super::file::base64_decode(exported["base64"].as_str().unwrap()); + let raster = designcraft_render::render_pdf_page(&bytes, 0, 842).unwrap(); + let px = raster.data().get(150 * usize::from(raster.width()) + 175).unwrap(); + assert!(px.r > 200 && px.g < 40, "PDF export retains red: {px:?}"); + let idml = s.execute("file.exportIdml", &json!({})).unwrap(); + let bytes = super::super::file::base64_decode(idml["base64"].as_str().unwrap()); + let imported = designcraft_idml::import_idml(&bytes).unwrap(); + let pdf_asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap(); + assert!(designcraft_render::pdf_layers(&pdf_asset.data).iter().all(|(_, on)| *on)); + assert!(s.execute("file.exportIdml", &json!({"embedImages":false})).is_err()); + assert_eq!(s.doc().unwrap().doc.assets.values().find(|a| a.name == "off.pdf").unwrap().data.as_slice(), pdf); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[]})).unwrap(); + assert!(!visible(&mut s), "legacy empty list still resets defaults"); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":false})).unwrap(); + s.execute("object.layerOptions", &json!({"ids":[id],"useDefaults":true})).unwrap(); + assert!(!visible(&mut s), "explicit reset restores defaults"); + } + #[test] + fn pdf_layer_options_ignore_non_pdf_targets_in_mixed_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let placed = s + .execute( + "file.place", + &json!({"base64": super::super::file::base64_encode(&two_layer_pdf()), "name":"art.pdf", "x":100,"y":100,"width":100}), + ) + .unwrap(); + let other = s.execute("frame.create", &json!({"rect":[0,0,20,20]})).unwrap(); + s.execute("object.layerOptions", &json!({"ids":[placed["id"],other["id"]],"hidden":[],"useDefaults":false})).unwrap(); + assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(other["id"].as_u64().unwrap())).unwrap().has_pdf_layer_override()); + assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"hidden":["Red"],"useDefaults":true})).is_err()); + assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"useDefaults":"false"})).is_err()); + } } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d119c3538..af1051ab0 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -22,10 +22,10 @@ use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; /// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults; -/// version 3 adds rendering of retained EPS source assets; version 4 retains placed graphic layer -/// options. Version 4 is one capability shared with explicit all-on placed-PDF layer choices -/// (storytold/designcraft#1671): it must not be released until a reader understands both, and any -/// later capability needs version 5. [`required_version`] is the only place that picks a version. +/// version 3 adds rendering of retained EPS source assets; version 4 is one capability covering both +/// retained placed-graphic layer options and explicit all-on placed-PDF layer choices, so a version 4 +/// reader understands both. Any later capability needs version 5. [`required_version`] is the only +/// place that picks a version. pub const VERSION: u32 = 4; #[derive(Debug, thiserror::Error)] @@ -73,8 +73,10 @@ const LARGE_ENTRY: usize = (u32::MAX as usize).saturating_sub(64 << 20); /// The oldest reader capability that keeps everything in `doc`. Documents that need nothing newer /// keep their older requirement so existing readers still open them. fn required_version(doc: &Document) -> u32 { - // Older readers ignore unknown Graphic fields and would silently drop layer choices. - if doc.has_graphic_layer_options() { + // Version 4 covers both of these: older readers ignore the unknown fields, so they would + // silently drop retained graphic layer choices, or read an explicit all-on PDF layer choice + // (empty hidden list) as "use the file's defaults". A nonempty hidden list stays readable. + if doc.has_graphic_layer_options() || doc.has_explicit_all_on_pdf_layers() { return 4; } // Older readers would silently omit EPS artwork. Preview-only legacy assets and documents @@ -454,6 +456,36 @@ mod tests { buf.into_inner() } + #[test] + fn explicit_all_on_pdf_layers_require_a_capable_reader_only_when_needed() { + let mut d = Document::new(&NewDocument::default()); + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { + id: aid, + name: "art.pdf".into(), + mime: "application/pdf".into(), + data: Arc::new(b"%PDF-1.7".to_vec()), + ..Default::default() + }), + ); + let id = place(&mut d, aid, 0.0); + let version = |d: &Document| { + let bytes = save(d).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut meta = String::new(); + z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap(); + serde_json::from_str::(&meta).unwrap()["version"].as_u64().unwrap() + }; + assert_eq!(version(&d), 2); + d.item_mut(id).unwrap().pdf_layer_override = true; + assert_eq!(version(&d), 4); + assert!(load(&save(&d).unwrap()).unwrap().item(id).unwrap().pdf_layer_override); + d.item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()]; + assert_eq!(version(&d), 2, "legacy nonempty overrides remain readable"); + } + #[test] fn eps_source_requires_new_reader_but_preview_documents_keep_the_older_one() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d9b527a5e..83e8def38 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -654,13 +654,10 @@ impl Renderer { let g = &if it.media.is_some() { it.drawn_graphic(&f.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() }; ctx.set_transform(f.view * xf); ctx.push_clip_layer(bp); - if !it.pdf_hidden_layers.is_empty() { - HIDDEN_LAYERS.with(|h| *h.borrow_mut() = it.pdf_hidden_layers.clone()); - } + let hidden = it.has_pdf_layer_override().then(|| it.pdf_hidden_layers.clone()); + let previous = HIDDEN_LAYERS.with(|h| h.replace(hidden)); self.draw_graphic(ctx, f, g, xf); - if !it.pdf_hidden_layers.is_empty() { - HIDDEN_LAYERS.with(|h| h.borrow_mut().clear()); - } + HIDDEN_LAYERS.with(|h| h.replace(previous)); ctx.pop_layer(); } // Items pasted into the frame, clipped by it. @@ -880,12 +877,14 @@ impl Renderer { let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt(); let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen }; let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone()); - let data = if !hidden.is_empty() && is_pdf(&asset.data) { - // Stale persisted/relinked choices must not reveal hidden source content. - let Some(variant) = pdf_layers::layered(&asset.data, &hidden) else { return }; - variant - } else { - asset.data.clone() + let data = match hidden.as_deref().filter(|_| is_pdf(&asset.data)) { + // An explicit choice (including all-on) that cannot be rewritten fails closed: + // stale persisted/relinked choices must not reveal hidden source content. + Some(hidden) => { + let Some(variant) = pdf_layers::layered(&asset.data, hidden) else { return }; + variant + } + None => asset.data.clone(), }; // A placed PDF cropped to a box: the whole page, placed so that the box spans graphic space. let [l, t, r, b] = shown_box(asset).unwrap_or([0.0, 0.0, 1.0, 1.0]); @@ -1025,7 +1024,7 @@ fn rect_overlaps(a: Rect, b: Rect) -> bool { thread_local! { /// Object Layer Options of the placed PDF being drawn. - static HIDDEN_LAYERS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + static HIDDEN_LAYERS: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; /// Appearance of Black for the render on this thread: 100% K as pure black. static RICH_BLACK: std::cell::Cell = const { std::cell::Cell::new(false) }; /// Separations Preview plate for the render on this thread. @@ -1296,11 +1295,23 @@ pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], /// Rasterize a placed page for export, preserving available interpreter diagnostics. /// This cannot detect backend approximations that do not emit an interpreter warning. pub fn pdf_page_png_checked(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Result, PdfRasterError> { + pdf_page_png_options_checked(bytes, page, max_side, (!hidden.is_empty()).then_some(hidden), shown) +} + +/// Rasterize with an explicit visibility override: `Some(&[])` enables every layer; +/// `None` retains file defaults. The legacy entry point keeps its empty-list semantics. +pub fn pdf_page_png_options_checked( + bytes: &[u8], + page: usize, + max_side: u32, + hidden: Option<&[String]>, + shown: Option<[f64; 4]>, +) -> Result, PdfRasterError> { check_pdf_input_size(bytes.len())?; - let data = if hidden.is_empty() { - std::borrow::Cow::Borrowed(bytes) - } else { + let data = if let Some(hidden) = hidden { std::borrow::Cow::Owned(pdf_hide_layers(bytes, hidden).ok_or(PdfRasterError::LayerRewrite)?) + } else { + std::borrow::Cow::Borrowed(bytes) }; let raster = render_pdf_raster(&data, page, max_side)?; if raster.diagnostics.image_decode_failed { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9b7bc2b79..f0ff60e09 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2411,8 +2411,15 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { && let Some(o) = l.as_object_mut() { o.insert("visible".into(), json!(on)); + d.fields.insert("useDefaults".into(), json!(false)); } } + if ui.button(crate::i18n::tr(&app.ui.language, "Use File Defaults")).clicked() { + for layer in &mut layers { + layer["visible"] = layer["default"].clone(); + } + d.fields.insert("useDefaults".into(), json!(true)); + } d.fields.insert("layers".into(), json!(layers)); } "colorSettings" => { @@ -3269,7 +3276,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "layerOptions" => { let hidden: Vec = d.fields.get("layers").and_then(Value::as_array).map(|a| a.iter().filter(|l| l["visible"] == false).map(|l| l["name"].clone()).collect()).unwrap_or_default(); - app.run("object.layerOptions", json!({"hidden": hidden})) + app.run("object.layerOptions", json!({"hidden": if d.b("useDefaults") { Vec::::new() } else { hidden }, "useDefaults": d.b("useDefaults")})) } "colorSettings" => { let r = app.run("color.settings", json!({"rgb": d.s("rgb"), "cmyk": d.s("cmyk"), "intent": d.s("intent"), "bpc": d.b("bpc")}))?; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3c052b783..c85a87420 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1556,7 +1556,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option v, Err(e) => return Some(Err(e.to_string())), }; - app.ui.dialog = Some(crate::dialogs::Dialog::new("layerOptions", json!({"layers": layers}))); + app.ui.dialog = Some(crate::dialogs::Dialog::new( + "layerOptions", + json!({"useDefaults": !layers.as_array().and_then(|a| a.first()).and_then(|l| l["overridden"].as_bool()).unwrap_or(false), "layers": layers}), + )); Ok(Value::Null) } "view.rotateSpread" => { diff --git a/docs/gaps.md b/docs/gaps.md index c9221039b..59ecc7122 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -207,6 +207,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Safety:** PDF visibility variants preserve catalog/configuration entries and nonzero-generation references, redefining the existing catalog rather than choosing an arbitrary object number. Encrypted, hybrid-reference and over-budget rewrites are rejected; no print-fidelity claim. +- **Layer choices:** placed PDFs distinguish explicit all-on visibility from file defaults; + choices survive native saves, raster/PDF output and embedded IDML export. IDML layer + variants are embedded copies; external-only export is rejected when choices would be lost. - **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer comps beyond visibility; the Clipping Path Options dialog (tracing into a clip that keeps the frame, Threshold/Tolerance/Inset Frame preview, picking an alpha channel or a path saved in a diff --git a/docs/pdf-layer-visibility.md b/docs/pdf-layer-visibility.md new file mode 100644 index 000000000..3362b9dbb --- /dev/null +++ b/docs/pdf-layer-visibility.md @@ -0,0 +1,9 @@ +# Placed PDF layer visibility + +Object Layer Options distinguishes a PDF's own default configuration from an explicit visibility choice, including showing every layer. Use File Defaults resets that choice. Only placed PDFs in a mixed selection are changed. + +The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Queries include `overridden` separately from each layer's `visible` and `default` states. + +Native saves use format 4 only for an explicit all-on override: older readers would otherwise silently render file defaults. Existing nonempty hidden lists retain their older semantics and format requirements. Raster and PDF export honor the choice; PDF export keeps its existing warned rasterization behavior. Embedded IDML export stores a separate PDF variant per placement and leaves original linked bytes untouched. External-only IDML export rejects active PDF layer choices because the original external file would lose the choice. + +This fixes visibility-state handling, not full InDesign layer/print parity. It does not implement editing the contents of placed PDFs or native INDD placement. Catalog/reference safety fixes are tracked separately. The concurrent graphic-layer metadata PR also changes native format capability; its version selection must be reconciled when merging both features. From 57658368af0e7b401b1d87a8980bc68bd0937c5e Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 23 Oct 2026 19:29:29 +0100 Subject: [PATCH 28/71] PDF: validate layer overrides and query mixed selections --- ROADMAP.md | 4 ++++ crates/engine/src/cmd/pdflayers.rs | 33 +++++++++++++++++++++++++++++- docs/pdf-layer-visibility.md | 2 +- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0f621b7ce..42a6b2d21 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,6 +25,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope regression tests cover metadata, output-intent references, nonzero generations and forward Prev links; repeated cross-reference offsets are rejected explicitly. No readiness percentage change. +- **2026-10-23** · PDF layer commands reject overrides on layerless files atomically; + default layer queries find a placed PDF anywhere in mixed selections. Regression tests cover + IDML export after rejection and both selection orders. + ## Status (2026-10-01) **Working today** diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index 8d339dc9d..1b1bdf61d 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -15,7 +15,7 @@ fn placed_pdf(s: &Session, id: designcraft_doc::ItemId) -> Option Vec { vec![ cmd!(query "object.pdfLayers", "Object Layers", [], None, "{id?} → [{name, visible, default}] — the layers of the selected placed PDF", has_selection, |s, p| { - let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.first().copied())).ok_or_else(|| bad("object.pdfLayers", "no selection"))?; + let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.iter().copied().find(|id| placed_pdf(s, *id).is_some()))).ok_or_else(|| bad("object.pdfLayers", "no selection"))?; let data = placed_pdf(s, id).ok_or_else(|| bad("object.pdfLayers", "not a placed PDF"))?; let item = s.doc()?.doc.item(id); let hidden = item.map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default(); @@ -51,6 +51,11 @@ pub fn specs() -> Vec { if ids.is_empty() { return Err(bad("object.layerOptions", "select a placed PDF")); } + // A layerless/unreadable PDF cannot carry a visibility variant. Validate + // every target before editing so a mixed request remains atomic. + if !use_defaults && ids.iter().any(|id| placed_pdf(s, *id).is_none_or(|data| designcraft_render::pdf_layers(&data).is_empty())) { + return Err(bad("object.layerOptions", "the selected PDF has no readable optional-content layers")); + } // Validate every selected PDF before editing any item or recording undo. // Repeated placements of the same source need only one rewrite check. if !hidden.is_empty() { @@ -434,6 +439,27 @@ pub(crate) mod tests { s.execute("object.layerOptions", &json!({"ids":[id],"useDefaults":true})).unwrap(); assert!(!visible(&mut s), "explicit reset restores defaults"); } + #[test] + fn layerless_pdf_rejects_overrides_without_breaking_idml_export() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let pdf = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); + let id = s.execute("file.place", &json!({"base64":pdf,"name":"plain.pdf","x":0,"y":0,"width":100})).unwrap()["id"].clone(); + let layered = s + .execute( + "file.place", + &json!({"base64":super::super::file::base64_encode(&two_layer_pdf()),"name":"layered.pdf","x":120,"y":0,"width":100}), + ) + .unwrap()["id"] + .clone(); + let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + assert!(s.execute("object.layerOptions", &json!({"ids":[layered,id],"hidden":[],"useDefaults":false})).is_err()); + assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before); + assert!(s.execute("file.exportIdml", &json!({})).is_ok()); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":true})).unwrap(); + assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().has_pdf_layer_override()); + } + #[test] fn pdf_layer_options_ignore_non_pdf_targets_in_mixed_selection() { let mut s = Session::new(); @@ -445,6 +471,11 @@ pub(crate) mod tests { ) .unwrap(); let other = s.execute("frame.create", &json!({"rect":[0,0,20,20]})).unwrap(); + for ids in [json!([other["id"], placed["id"]]), json!([placed["id"], other["id"]])] { + s.execute("selection.set", &json!({"ids":ids})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({})).unwrap().as_array().unwrap().len(), 2); + } + assert!(s.execute("object.pdfLayers", &json!({"id":other["id"]})).is_err(), "explicit non-PDF id remains an error"); s.execute("object.layerOptions", &json!({"ids":[placed["id"],other["id"]],"hidden":[],"useDefaults":false})).unwrap(); assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(other["id"].as_u64().unwrap())).unwrap().has_pdf_layer_override()); assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"hidden":["Red"],"useDefaults":true})).is_err()); diff --git a/docs/pdf-layer-visibility.md b/docs/pdf-layer-visibility.md index 3362b9dbb..a6c7bed38 100644 --- a/docs/pdf-layer-visibility.md +++ b/docs/pdf-layer-visibility.md @@ -2,7 +2,7 @@ Object Layer Options distinguishes a PDF's own default configuration from an explicit visibility choice, including showing every layer. Use File Defaults resets that choice. Only placed PDFs in a mixed selection are changed. -The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Queries include `overridden` separately from each layer's `visible` and `default` states. +The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Overrides on PDFs without readable optional-content layers are rejected before any target is edited. With no explicit ID, a query selects the first placed PDF anywhere in the selection; an explicit non-PDF ID remains an error. Queries include `overridden` separately from each layer's `visible` and `default` states. Native saves use format 4 only for an explicit all-on override: older readers would otherwise silently render file defaults. Existing nonempty hidden lists retain their older semantics and format requirements. Raster and PDF export honor the choice; PDF export keeps its existing warned rasterization behavior. Embedded IDML export stores a separate PDF variant per placement and leaves original linked bytes untouched. External-only IDML export rejects active PDF layer choices because the original external file would lose the choice. From 4be45a713af087057273b5dccee35c444fcd23bb Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:30:03 -0400 Subject: [PATCH 29/71] Anchored text frames compose against the grid and page where they are placed (#1710) * Anchored text frames compose against the grid and page where they are placed An anchored text frame is not a spread item, so frame_specs composed it at its stored transform: no page, no page side, no page rect, and a baseline grid phase taken from that transform. A note set to Only Align First Line to Grid snapped to the wrong line, and page-number markers and spine-relative alignment inside it saw no page. The composer now looks up the host story's composition first, finds where it placed the frame (in its frames' anchored objects, footnotes or table cells) and composes the frame's story against that placement (ComposeOptions::placed). The cache does the same and adds the placement to the entry's signature, so a note recomposes when its host moves it. Host lookups stop after eight levels, which bounds stories anchored in each other. * Anchored text frames: the cache keys on the placed spread's pages --- ROADMAP.md | 1 + crates/compose/src/cache.rs | 36 +++++++++++---- crates/compose/src/lib.rs | 37 +++++++++++++-- crates/compose/src/notes.rs | 1 + crates/compose/src/placed.rs | 84 ++++++++++++++++++++++++++++++++++ crates/compose/src/tests.rs | 88 ++++++++++++++++++++++++++++++++++++ crates/compose/src/wrap.rs | 2 +- 7 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 crates/compose/src/placed.rs diff --git a/ROADMAP.md b/ROADMAP.md index 5c739f11e..c997b9e35 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 70e22710d..66122ccf4 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -3,7 +3,8 @@ //! An entry is valid while the inputs it was composed from are the *same allocations*: the story //! `Arc` (and its revision), the styles `Arc`, the frame items' `Arc`s and the `Arc`s of every //! wrapping item on the frames' spreads, and the same for every story whose anchored objects wrap -//! its text ([`crate::wrap::neighbours`]). Entries hold clones of those `Arc`s, so the pointers +//! its text ([`crate::wrap::neighbours`]). An anchored frame's entry also records where its host +//! story's composition places it. Entries hold clones of those `Arc`s, so the pointers //! can't be reused by other allocations while the entry lives. use std::collections::HashMap; @@ -99,11 +100,12 @@ impl Cache { /// [`Cache::get`] for any [`ShownOn`]. pub fn get_on(&self, doc: &Document, sid: StoryId, on: ShownOn) -> Arc { - self.get_with(doc, sid, on, true) + self.get_with(doc, sid, on, true, 0) } - /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set. - fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool) -> Arc { + /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for + /// a story `depth` hosts down from the story asked for. + fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize) -> Arc { let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) }; let (mut sig, mut keep_items) = signature(doc, story); let neighbours = if cross { crate::wrap::neighbours(doc, sid) } else { Vec::new() }; @@ -117,6 +119,24 @@ impl Cache { keep_stories.push(ns.clone()); } } + // Anchored frames compose where their host story places them, so the host is composed + // first and the placements are part of the signature. + let placed = if depth < crate::placed::MAX_DEPTH { + crate::placed::placements(doc, sid, &mut |host| self.get_with(doc, host, ShownOn::Own, true, depth + 1)) + } else { + Vec::new() + }; + for (id, p) in &placed { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (id, p.spread, p.xf.as_coeffs().map(f64::to_bits)).hash(&mut h); + // The placed spread's pages give the frame its page, side and page rect. + for pg in doc.spread(p.spread).map_or(&[][..], |sp| &sp.pages[..]) { + let m = &pg.margins; + (pg.side as u8, [pg.x, pg.width, pg.height, m.top, m.bottom, m.inside, m.outside].map(f64::to_bits)).hash(&mut h); + } + sig.push(h.finish() as usize); + } let key = (sid, on, !neighbours.is_empty()); // 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. @@ -209,11 +229,11 @@ impl Cache { // Parent-page items are composed per page: markers show its name, variables need its index. let page = page.filter(|_| with_vars); let running = running.then(|| self.running_index(doc)); - let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n)).collect(); + let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n, depth)).collect(); let out = Arc::new(crate::wrap::compose_among( doc, sid, - &ComposeOptions { page_name, section_marker, 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)), placed }, &others.iter().map(|o| &**o).collect::>(), )); let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner); @@ -295,13 +315,13 @@ impl Cache { /// Story `sid` composed for the wrap of its anchored objects in other stories: without other /// stories' anchored objects (no cycles), and outside the cache when it needs the document-wide /// indexes (they compose every story). - fn neighbour(&self, doc: &Document, sid: StoryId) -> Arc { + fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize) -> Arc { let indexed = doc.stories.get(&sid).is_some_and(|st| crate::xref::has_xrefs(st) || (crate::vars::has_vars(st) && crate::vars::has_running(doc, st))); if indexed { Arc::new(crate::wrap::compose_among(doc, sid, &ComposeOptions::default(), &[])) } else { - self.get_with(doc, sid, ShownOn::Own, false) + self.get_with(doc, sid, ShownOn::Own, false, depth) } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e80121543..fc166167d 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -23,6 +23,7 @@ pub mod optical; mod outline; mod overlay; mod parabox; +mod placed; mod ruby; pub mod shape; pub mod shatai; @@ -50,6 +51,7 @@ use crate::notes::Notes; pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret}; pub use crate::outline::FrameOutline; use crate::parabox::ParaBox; +pub use crate::placed::Placement; use crate::shape::{Glyph, StyleTable, SubstCtx}; pub use crate::table::{BorderHit, PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_border, hit_cell, hit_cell_with}; @@ -627,6 +629,9 @@ pub struct ComposeOptions { pub label: Option, /// Anchor pages for cross-references (needed when the story contains cross-references). pub xrefs: Option>, + /// Where the story's anchored frames sit on their spreads; an anchored frame not listed is + /// composed where it is stored, on no page. + pub placed: Vec<(ItemId, Placement)>, } /// Nesting depth past which wrap obstacles inside groups are not collected. @@ -659,21 +664,34 @@ fn wrap_obstacles(it: &Item, parent: Affine, inv: Affine, skip: ItemId, left_pag /// Build the frame specs of a story from the document (geometry, wrap, page names, grid). pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { + frame_specs_placed(doc, sid, &[]) +} + +/// [`frame_specs`] with anchored frames where their host story placed them (`placed`). +pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId, Placement)]) -> Vec { let Some(st) = doc.story(sid) else { return vec![] }; let mut out = Vec::with_capacity(st.frames.len()); for &fid in &st.frames { let loc = doc.find(fid); let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue }; let Some(tf) = item.text_frame() else { continue }; - let parent = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY); + // An anchored frame takes its page, page side, page rect and grid from where it is placed. + let placement = if loc.is_none() { placed.iter().find(|(id, _)| *id == fid).map(|(_, p)| *p) } else { None }; + let parent = match (&loc, placement) { + (Some(l), _) => doc.parent_xf(l), + (None, Some(p)) => p.xf, + (None, None) => Affine::IDENTITY, + }; let xf = parent * item.xf; // Where the frame is on the spread (a group's transform included), to find its page. let center_x = parent.transform_rect_bbox(item.bounds()).center().x; let inv = xf.inverse(); let mut exclusions = Vec::new(); - let spread_ref = loc.as_ref().map(|l| l.spread); + let spread_ref = loc.as_ref().map(|l| l.spread).or(placement.map(|p| p.spread)); let spread = spread_ref.and_then(|r| doc.spread(r)); + // Page items wrap the text of frames on the spread, not of anchored frames. if !tf.options.ignore_wrap + && loc.is_some() && let Some(sp) = spread { let left = |x: f64| sp.page_at_x(x).and_then(|pi| sp.pages.get(pi)).is_some_and(|p| p.side == designcraft_doc::PageSide::Left); @@ -783,10 +801,23 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { /// Compose a story of `doc` through its frames, with the text wrap of page items and of anchored /// objects ([`wrap`]). +/// Anchored frames are composed where their host story places them (unless `opts` places them). pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory { + compose_story_at(doc, sid, opts, 0) +} + +/// [`compose_story`] for a story `depth` hosts down from the story asked for. +fn compose_story_at(doc: &Document, sid: StoryId, opts: &ComposeOptions, depth: usize) -> ComposedStory { let others: Vec = wrap::neighbours(doc, sid).into_iter().map(|o| wrap::compose_among(doc, o, &ComposeOptions::default(), &[])).collect(); - wrap::compose_among(doc, sid, opts, &others.iter().collect::>()) + let others: Vec<&ComposedStory> = others.iter().collect(); + if opts.placed.is_empty() && depth < placed::MAX_DEPTH { + let placed = placed::placements(doc, sid, &mut |host| Arc::new(compose_story_at(doc, host, &ComposeOptions::default(), depth + 1))); + if !placed.is_empty() { + return wrap::compose_among(doc, sid, &ComposeOptions { placed, ..opts.clone() }, &others); + } + } + wrap::compose_among(doc, sid, opts, &others) } const DEFAULT_TAB: f64 = 36.0; diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 2a465fbfa..9b3e7e40b 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -180,6 +180,7 @@ impl Notes { running: opts.running.clone(), label: Some(label), xrefs: opts.xrefs.clone(), + placed: Vec::new(), }; let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts); let lines = cs.frames.first().map(|ft| ft.lines.as_slice()).unwrap_or_default(); diff --git a/crates/compose/src/placed.rs b/crates/compose/src/placed.rs new file mode 100644 index 000000000..200ff6e3c --- /dev/null +++ b/crates/compose/src/placed.rs @@ -0,0 +1,84 @@ +//! Where anchored text frames sit on their spread. +//! +//! An anchored frame is not a spread item: its host story's composition places it on a line. Its +//! own story is composed against that placement (page, page side, page rect, baseline grid), so +//! the host story is composed first. + +use std::sync::Arc; + +use designcraft_doc::{Document, Item, ItemId, SpreadRef, StoryId}; +use designcraft_geom::Affine; + +use crate::{ComposedStory, FrameText}; + +/// Hosts nested deeper than this (a note anchored in a note anchored in …, or stories anchored in +/// each other in a cycle) are composed where they are stored. +pub(crate) const MAX_DEPTH: usize = 8; + +/// An anchored item's parent space on its spread, where its host story's composition placed it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub spread: SpreadRef, + /// The item's parent space → spread space. + pub xf: Affine, +} + +/// The placements of `sid`'s anchored frames, from their host stories composed by `host`. +pub(crate) fn placements(doc: &Document, sid: StoryId, host: &mut dyn FnMut(StoryId) -> Arc) -> Vec<(ItemId, Placement)> { + let Some(st) = doc.story(sid) else { return Vec::new() }; + let mut anchored = None; + let mut out = Vec::new(); + for &fid in &st.frames { + if doc.find(fid).is_some() { + continue; + } + let items = anchored.get_or_insert_with(|| doc.anchored_items()); + // A frame anchored in its own story would need its own composition to place it. + let Some(owner) = items.iter().find_map(|(owner, it)| (it.id == fid).then_some(*owner)).filter(|o| *o != sid) else { continue }; + if let Some(p) = locate(doc, &host(owner), fid) { + out.push((fid, p)); + } + } + out +} + +/// Where `cs` (a host story's composition) places item `fid`: in its frames' anchored objects, +/// footnotes and table cells. +fn locate(doc: &Document, cs: &ComposedStory, fid: ItemId) -> Option { + cs.frames.iter().find_map(|ft| { + let loc = doc.find(ft.frame)?; + let frame = doc.item_at(&loc)?; + let xf = in_frame(ft, doc.parent_xf(&loc) * doc.text_xf(frame), fid, 0)?; + Some(Placement { spread: loc.spread, xf }) + }) +} + +/// The parent space of `fid` among the objects of `ft`, whose text space maps to the spread by +/// `xf`. +fn in_frame(ft: &FrameText, xf: Affine, fid: ItemId, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + let objects = ft.objects.iter().find_map(|o| in_item(&o.object.item, xf * Affine::translate(o.origin.to_vec2()), fid, 0)); + objects + .or_else(|| ft.notes.iter().find_map(|n| in_frame(n.text.frames.first()?, xf * Affine::translate(n.origin.to_vec2()), fid, depth + 1))) + .or_else(|| { + ft.tables + .iter() + .flat_map(|t| &t.cells) + .find_map(|c| in_frame(c.text.frames.first()?, xf * Affine::translate(c.origin.to_vec2()), fid, depth + 1)) + }) +} + +/// The parent space of `fid` in `it` (it or a group member), `it`'s parent space mapping to the +/// spread by `parent`. +fn in_item(it: &Item, parent: Affine, fid: ItemId, depth: usize) -> Option { + if it.id == fid { + return Some(parent); + } + if depth > crate::MAX_WRAP_DEPTH { + return None; + } + let xf = parent * it.xf; + it.children().iter().find_map(|c| in_item(c, xf, fid, depth + 1)) +} diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6e1784f5e..444e34ebb 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7658,3 +7658,91 @@ fn an_rtl_first_line_indent_is_at_the_right_edge() { assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0])); assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1])); } + +/// A marginal note (an anchored text frame, Only Align First Line to Grid, 9/10) hangs 15 pt +/// above its anchor's baseline on a right-hand page. It composes against the document grid where +/// it is placed: its first line moves down onto the grid line, the second follows 10 pt below, +/// and its page is the host's. Composed where it was stored, it snapped to a grid measured from +/// its own top edge and sat on no page. +#[test] +fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() { + use designcraft_doc::anchored::AnchorRelative as R; + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + d.settings.baseline_grid.start = 36.0; + d.settings.baseline_grid.increment = 10.0; + let lid = d.default_layer(); + let page = d.spreads[1].pages[1].clone(); + assert_eq!(page.side, designcraft_doc::PageSide::Right); + // Body lines at 46, 56, 66, … (on the grid) while the frame top is at 36. + let body = "One.\nTwo.\nThree.\nFour."; + let rect = Rect::new(page.x + 108.0, 36.0, page.x + page.width - 36.0, 400.0); + let (bf, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, body, ParaFormat::default()).unwrap(); + d.item_mut(bf).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading; + let nine_ten = |f: &mut designcraft_doc::story::CharFormat| { + f.over.size = Some(9.0); + f.over.leading = Some(designcraft_doc::Leading::Points(10.0)); + }; + d.story_mut(sid).unwrap().format_chars(0..body.len(), nine_ten); + // The note: its own story in a 60 × 40 frame anchored in the third line. + let (nid, nsid) = (ItemId(d.alloc()), StoryId(d.alloc())); + let text = "Note one\u{2028}note two"; + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::FirstLineOnly), ..Default::default() }, ..Default::default() }; + let mut story = Story::with_text(nsid, text, para); + story.format_chars(0..text.len(), nine_ten); + story.frames.push(nid); + d.stories.insert(nsid, Arc::new(story)); + let mut note = + designcraft_doc::Item::new(nid, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 40.0))); + note.content = Content::Text(designcraft_doc::TextFrame { story: nsid, options: Default::default() }); + let position = designcraft_doc::AnchorPosition::Custom { + x_relative: R::TextFrame, + y_relative: R::Anchor, + x_offset: -72.0, + y_offset: -15.0, + object_point: 0, + ref_point: 0, + keep_within_column: false, + spine_relative: false, + }; + let at = body.find("Three").unwrap(); + d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position)); + + let cache = Cache::new(); + let on_grid = |y: f64| on_grid(y, (36.0, 10.0)); + // (anchor line baseline, note baselines), in spread space. + let baselines = |d: &Document, cs: &ComposedStory| { + let host = compose_story(d, sid, &ComposeOptions::default()); + let ft = host.frame(bf).unwrap(); + let o = &ft.objects[0]; + let fxf = d.item(bf).unwrap().xf; + let anchor = (fxf * Point::new(0.0, ft.lines[o.line].baseline)).y; + let nxf = fxf * Affine::translate(o.origin.to_vec2()) * d.anchored_item(nid).unwrap().xf; + (anchor, all_lines(cs).iter().map(|l| (nxf * Point::new(0.0, l.baseline)).y).collect::>()) + }; + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + + let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None)); + assert!(near(anchor, 66.0), "anchor baseline {anchor}"); + assert_eq!(lines.len(), 2); + assert!(near(lines[0], anchor) && near(lines[1], anchor + 10.0), "note baselines {lines:?}, anchor {anchor}"); + assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines); + // The note is on the host's right-hand page, with that page's rectangle. + let placed = placed::placements(&d, nsid, &mut |h| Arc::new(compose_story(&d, h, &ComposeOptions::default()))); + let spec = &frame_specs_placed(&d, nsid, &placed)[0]; + assert_eq!((spec.page, spec.right_page, spec.left_page), (Some(2), true, false)); + assert!(spec.page_rect.is_some()); + + // Host moved down 5 pt: the anchor is off the grid, and the note's first line moves down from + // 10 pt above it onto the grid line 5 pt above it. The cache composes the note again. + let xf = d.item(bf).unwrap().xf; + d.item_mut(bf).unwrap().xf = Affine::translate((0.0, 5.0)) * xf; + let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None)); + assert!(near(anchor, 71.0), "anchor baseline {anchor}"); + assert!(on_grid(lines[0]) && near(lines[0], anchor - 5.0) && near(lines[1], lines[0] + 10.0), "note baselines {lines:?}, anchor {anchor}"); + assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines); + + // The host's page turns into a left-hand page: the note is composed again for that side. + let before = cache.get(&d, nsid, None); + Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left; + assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None))); +} diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 57657213f..e1cdc5fa7 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -182,7 +182,7 @@ fn settled(a: &[Vec], b: &[Vec]) -> bool { /// [`neighbours`]) and of its own. pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, others: &[&ComposedStory]) -> ComposedStory { let Some(st) = doc.story(sid) else { return ComposedStory { story: sid, ..Default::default() } }; - let mut specs = crate::frame_specs(doc, sid); + let mut specs = crate::frame_specs_placed(doc, sid, &opts.placed); if !others.is_empty() { let mut obs = Vec::new(); for cs in others { From 96a4a9060b12f93e9e8bdb25548cc9e019d5c7cd Mon Sep 17 00:00:00 2001 From: Andee Collard Date: Sat, 24 Oct 2026 13:51:19 +0100 Subject: [PATCH 30/71] Format: test one version 4 reader keeping both layer capabilities skylartaylor review of #1647/#1671: both PRs introduced native format 4 for different capabilities. Stacked on #1647, version 4 is one capability: required_version (the single place that picks a version) asks for it when a document has retained placed-graphic layer options or an explicit all-on PDF layer choice, so a version 4 reader understands both. That merge was made while rebasing "PDF: distinguish all-on layer choices from file defaults", which also replaced its Document::all_items scan (document spreads only) with Document::has_explicit_all_on_pdf_layers, covering parent spreads, groups, anchored objects and nested story content; an all-on PDF on a parent page previously saved as version 2. This save/reopen test holds both features in one document, checks each alone still needs 4, that documents with neither keep 3 (EPS) or 2, and that a parent-spread all-on choice needs 4. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/lib.rs | 59 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index af1051ab0..e33183dc2 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -486,6 +486,65 @@ mod tests { assert_eq!(version(&d), 2, "legacy nonempty overrides remain readable"); } + #[test] + fn one_version_4_reader_keeps_both_explicit_all_on_and_graphic_layer_options() { + use designcraft_doc::{Content, GraphicLayerOptions}; + let mut d = Document::new(&NewDocument::default()); + let pdf = AssetId(d.alloc()); + d.assets.insert( + pdf, + Arc::new(Asset { + id: pdf, + name: "art.pdf".into(), + mime: "application/pdf".into(), + data: Arc::new(b"%PDF-1.7".to_vec()), + ..Default::default() + }), + ); + let eps = AssetId(d.alloc()); + let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec(); + d.assets.insert( + eps, + Arc::new(Asset { id: eps, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(source), ..Default::default() }), + ); + let all_on = place(&mut d, pdf, 0.0); + let layered = place(&mut d, eps, 0.0); + assert_eq!(required_version(&d), 3, "neither feature: EPS documents keep version 3"); + let options = GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() }; + let set_options = |d: &mut Document, value: Option| { + let Some(Content::Graphic(g)) = d.item_mut(layered).map(|it| &mut it.content) else { panic!("placed graphic") }; + g.layer_options = value; + }; + set_options(&mut d, Some(options.clone())); + d.item_mut(all_on).unwrap().pdf_layer_override = true; + let bytes = save(&d).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap(); + let mut meta = String::new(); + z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap(); + assert_eq!(serde_json::from_str::(&meta).unwrap()["version"], 4); + let reopened = load(&bytes).unwrap(); + let it = reopened.item(all_on).unwrap(); + assert!(it.pdf_layer_override && it.pdf_hidden_layers.is_empty(), "explicit all-on survives"); + assert_eq!(reopened.item(layered).unwrap().graphic().unwrap().layer_options.as_ref(), Some(&options), "graphic layer options survive"); + assert_eq!(required_version(&reopened), 4); + // Either feature alone needs version 4; dropping both restores the older requirement. + set_options(&mut d, None); + assert_eq!(required_version(&d), 4, "explicit all-on alone"); + d.item_mut(all_on).unwrap().pdf_layer_override = false; + assert_eq!(required_version(&d), 3); + set_options(&mut d, Some(options)); + assert_eq!(required_version(&d), 4, "graphic layer options alone"); + set_options(&mut d, None); + d.assets.remove(&eps); + d.remove_item(layered).unwrap(); + assert_eq!(required_version(&d), 2, "neither feature and no EPS: version 2"); + // An explicit all-on choice on a parent spread also needs the version 4 reader. + let mut parent_item = (*d.remove_item(all_on).unwrap()).clone(); + parent_item.pdf_layer_override = true; + d.insert_item(SpreadRef::Parent(0), parent_item, None).unwrap(); + assert_eq!(required_version(&d), 4, "parent-spread placements are not skipped"); + } + #[test] fn eps_source_requires_new_reader_but_preview_documents_keep_the_older_one() { let mut d = Document::new(&NewDocument::default()); From aeccc9c5d3ea2828905e82f003c7fbbc547fefd7 Mon Sep 17 00:00:00 2001 From: Andee Collard Date: Sat, 24 Oct 2026 13:51:19 +0100 Subject: [PATCH 31/71] PDF: validate an actual layer rewrite for explicit all-on choices skylartaylor's review: a PDF can list readable layers yet fail the visibility rewrite. On a synthetic 33-section xref history, object.layerOptions with useDefaults:false and an empty hidden list was accepted (only hidden names triggered #1670's rewrite check), the canvas could not draw the choice and PDF export then failed. Every explicit choice now runs one actual pdf_hide_layers rewrite per distinct source before any item is edited or undo is recorded; this subsumes the separate layerless check. Resetting to file defaults (useDefaults:true or the legacy empty list) needs no variant and bypasses the check. #1670's fail-closed canvas handling is kept alongside the explicit-override state: a stored explicit choice (including all-on) whose source can no longer be rewritten draws nothing rather than the unmodified source, and export errors. The new regression test fails before this change (the all-on request is accepted) and covers both selection orders, atomicity, undo depth, reset bypass and the stale all-on fail-closed path. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 ++ crates/engine/src/cmd/pdflayers.rs | 65 +++++++++++++++++++++++------- docs/pdf-layer-visibility.md | 6 +-- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 42a6b2d21..0de1cfdde 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,9 @@ 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. +- **2026-10-24** · Explicit PDF layer choices, including all-on, are checked with one actual + rewrite per distinct source before editing; reset to file defaults bypasses the check. Native + format 4 covers both explicit all-on PDF layers and retained graphic-layer options. No readiness percentage change. - **2026-10-23** · Placed PDF layers can explicitly all be shown, separately from Use File Defaults. Legacy commands keep their reset behavior; native saves require a capable reader only for the new all-on state, and PDF/embedded IDML exports preserve the choice. No readiness percentage change. diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index 1b1bdf61d..c4fe0a31f 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -51,23 +51,20 @@ pub fn specs() -> Vec { if ids.is_empty() { return Err(bad("object.layerOptions", "select a placed PDF")); } - // A layerless/unreadable PDF cannot carry a visibility variant. Validate - // every target before editing so a mixed request remains atomic. - if !use_defaults && ids.iter().any(|id| placed_pdf(s, *id).is_none_or(|data| designcraft_render::pdf_layers(&data).is_empty())) { - return Err(bad("object.layerOptions", "the selected PDF has no readable optional-content layers")); - } - // Validate every selected PDF before editing any item or recording undo. - // Repeated placements of the same source need only one rewrite check. - if !hidden.is_empty() { + // Validate every selected PDF before editing any item or recording undo, so a + // mixed request stays atomic. Any explicit choice, including all-on + // (useDefaults:false with no hidden names), needs one actual rewrite per distinct + // source: a PDF can list readable layers and still fail the rewrite, and a + // layerless PDF cannot be rewritten at all. Resetting to the file defaults needs no + // variant and is always allowed. + if !use_defaults { let mut checked = std::collections::HashSet::new(); for id in &ids { - if let Some(data) = placed_pdf(s, *id) - && checked.insert(std::sync::Arc::as_ptr(&data)) - && designcraft_render::pdf_hide_layers(&data, &hidden).is_none() - { + let Some(data) = placed_pdf(s, *id) else { continue }; + if checked.insert(std::sync::Arc::as_ptr(&data)) && designcraft_render::pdf_hide_layers(&data, &hidden).is_none() { return Err(bad( "object.layerOptions", - "the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF", + "the selected PDF layers could not be changed (no readable optional-content layers, or an unsupported file); use the file defaults or replace the PDF", )); } } @@ -258,7 +255,7 @@ pub(crate) mod tests { let redo = s.doc().unwrap().history.redo.len(); for ids in [json!([good, bad]), json!([bad, good])] { let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err(); - assert!(err.to_string().contains("could not be hidden")); + assert!(err.to_string().contains("could not be changed")); assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before); assert_eq!(s.doc().unwrap().history.undo.len(), undo); assert_eq!(s.doc().unwrap().history.redo.len(), redo); @@ -278,6 +275,46 @@ pub(crate) mod tests { s.execute("file.exportPdf", &json!({})).unwrap(); } + #[test] + fn explicit_all_on_validates_an_actual_rewrite_before_editing() { + // 33 xref sections: the layers stay readable, but the visibility rewrite refuses the history. + let mut unsupported = two_layer_pdf(); + for _ in 0..32 { + add_pdf_revision(&mut unsupported); + } + assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2); + assert!(designcraft_render::pdf_hide_layers(&unsupported, &[]).is_none()); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap(); + let good = place(&mut s, &two_layer_pdf(), 0.0); + let bad = place(&mut s, &unsupported, 100.0); + let before = s.execute("file.serialize", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for ids in [json!([good, bad]), json!([bad, good]), json!([bad])] { + let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": [], "useDefaults": false})).unwrap_err(); + assert!(err.to_string().contains("could not be changed"), "{err}"); + assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before, "rejected all-on must not edit any target"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + assert_eq!(pixel(&s, 175), [255, 0, 0, 255], "the unsupported source keeps showing its file defaults"); + s.execute("file.exportPdf", &json!({})).unwrap(); + // The supported source alone still accepts the explicit all-on choice. + s.execute("object.layerOptions", &json!({"ids": [good], "hidden": [], "useDefaults": false})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": good})).unwrap()[1]["overridden"], true); + // Resetting to file defaults needs no variant, so it bypasses the rewrite check. + for reset in [json!({"ids": [good, bad], "hidden": []}), json!({"ids": [good, bad], "useDefaults": true})] { + s.execute("object.layerOptions", &reset).unwrap(); + for id in [&good, &bad] { + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["overridden"], false); + } + } + // A stale explicit all-on choice that outlived its rewritable source still fails closed. + let bad_id = serde_json::from_value(bad.clone()).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_layer_override = true; + assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "unrewritable explicit choice must not draw the unmodified source"); + assert!(s.execute("file.exportPdf", &json!({})).is_err()); + } + #[test] fn unreadable_non_null_prev_entries_refuse_layer_rewrites() { for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] { diff --git a/docs/pdf-layer-visibility.md b/docs/pdf-layer-visibility.md index a6c7bed38..c93e704b4 100644 --- a/docs/pdf-layer-visibility.md +++ b/docs/pdf-layer-visibility.md @@ -2,8 +2,8 @@ Object Layer Options distinguishes a PDF's own default configuration from an explicit visibility choice, including showing every layer. Use File Defaults resets that choice. Only placed PDFs in a mixed selection are changed. -The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Overrides on PDFs without readable optional-content layers are rejected before any target is edited. With no explicit ID, a query selects the first placed PDF anywhere in the selection; an explicit non-PDF ID remains an error. Queries include `overridden` separately from each layer's `visible` and `default` states. +The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Every explicit choice, including all-on (`useDefaults: false` with an empty `hidden` list), is checked with one actual visibility rewrite per distinct source before any target is edited or an undo step is recorded: a PDF can list readable layers yet refuse the rewrite (for example an over-budget cross-reference history), and PDFs without readable optional-content layers cannot be rewritten at all. Resetting to file defaults needs no rewrite and is always allowed. A stored choice whose source can no longer be rewritten still fails closed: the canvas does not draw the unmodified source and PDF export reports an error. With no explicit ID, a query selects the first placed PDF anywhere in the selection; an explicit non-PDF ID remains an error. Queries include `overridden` separately from each layer's `visible` and `default` states. -Native saves use format 4 only for an explicit all-on override: older readers would otherwise silently render file defaults. Existing nonempty hidden lists retain their older semantics and format requirements. Raster and PDF export honor the choice; PDF export keeps its existing warned rasterization behavior. Embedded IDML export stores a separate PDF variant per placement and leaves original linked bytes untouched. External-only IDML export rejects active PDF layer choices because the original external file would lose the choice. +Native saves use format 4 only for an explicit all-on override, on document or parent spreads, in groups or anchored: older readers would otherwise silently render file defaults. Format 4 is one reader capability shared with retained placed-graphic layer options (#1647, see [graphic-layer-options.md](graphic-layer-options.md)), so a version 4 reader understands both. Existing nonempty hidden lists retain their older semantics and format requirements. Raster and PDF export honor the choice; PDF export keeps its existing warned rasterization behavior. Embedded IDML export stores a separate PDF variant per placement and leaves original linked bytes untouched. External-only IDML export rejects active PDF layer choices because the original external file would lose the choice. -This fixes visibility-state handling, not full InDesign layer/print parity. It does not implement editing the contents of placed PDFs or native INDD placement. Catalog/reference safety fixes are tracked separately. The concurrent graphic-layer metadata PR also changes native format capability; its version selection must be reconciled when merging both features. +This fixes visibility-state handling, not full InDesign layer/print parity. It does not implement editing the contents of placed PDFs or native INDD placement. Catalog/reference safety fixes landed in #1670. From 42d2857fcc6066013dddad565d87a38a84016847 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 05:24:37 -0700 Subject: [PATCH 32/71] =?UTF-8?q?Type=20=E2=80=BA=20Notes=20=E2=80=BA=20Pr?= =?UTF-8?q?evious=20Note=20and=20Next=20Note:=20the=20insertion=20point=20?= =?UTF-8?q?moves=20from=20note=20to=20note,=20as=20in=20InDesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new commands note.previous and note.next move the insertion point to the previous or next editorial note in its story and put the caret after the note's anchor. Previous Note skips the note whose anchor the caret directly follows, so repeated use reaches every note. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. The items follow the Notes panel item in Type › Notes, with translations in the catalogs that already translate Next Document and Previous Document. --- ROADMAP.md | 1 + crates/engine/src/cmd/editorial.rs | 105 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/menus.rs | 3 + 14 files changed, 131 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index c997b9e35..b3bd74a02 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 5b210da90..618a735ff 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -93,6 +93,24 @@ pub fn specs() -> Vec { } Ok(Value::Array(out)) }), + cmd!( + noundo "note.previous", + "Previous Note", + ["Type", "Notes"], + None, + "{} → {story, id, pos}. The caret moves after the anchor of the story's previous note.", + has_previous, + |s, _| go_to(s, false) + ), + cmd!( + noundo "note.next", + "Next Note", + ["Type", "Notes"], + None, + "{} → {story, id, pos}. The caret moves after the anchor of the story's next note.", + has_next, + |s, _| go_to(s, true) + ), ] } @@ -108,6 +126,53 @@ fn anchor(st: &designcraft_doc::Story, id: u64) -> Option { st.text.match_indices(NOTE_MARK).nth(k).map(|(i, _)| i) } +/// The anchor offset and id of the note before or after the selection `t` in its story. Going +/// back skips the note whose anchor the caret directly follows, so repeated steps reach every note. +fn neighbor(st: &designcraft_doc::Story, t: &designcraft_doc::TextSel, forward: bool) -> Option<(usize, u64)> { + let r = t.range(); + let mut anchors = st.text.match_indices(NOTE_MARK).enumerate().map(|(k, (i, _))| (k, i)); + let (k, at) = if forward { anchors.find(|&(_, i)| i >= r.end) } else { anchors.filter(|&(_, i)| i + NOTE_MARK.len_utf8() < r.start).last() }?; + Some((at, st.editorial.get(k)?.id)) +} + +fn has_note(s: &crate::Session, forward: bool) -> std::result::Result<(), String> { + has_text(s)?; + match s.active().and_then(|d| Some((d, d.selection.text?))) { + Some((_, t)) if t.cell.is_some() => Err("notes go in story text".into()), + Some((d, t)) if d.doc.story(t.story).and_then(|st| neighbor(st, &t, forward)).is_some() => Ok(()), + _ => Err(no_note(forward).into()), + } +} + +fn has_previous(s: &crate::Session) -> std::result::Result<(), String> { + has_note(s, false) +} + +fn has_next(s: &crate::Session) -> std::result::Result<(), String> { + has_note(s, true) +} + +fn no_note(forward: bool) -> &'static str { + if forward { "no later note in this story" } else { "no earlier note in this story" } +} + +/// Type › Notes › Previous Note and Next Note: from the insertion point, the caret moves after +/// the anchor of the story's previous or next note. +fn go_to(s: &mut crate::Session, forward: bool) -> Result { + let id = if forward { "note.next" } else { "note.previous" }; + let st = s.doc_mut()?; + let t = st.selection.text.ok_or_else(|| bad(id, "place the insertion point in text"))?; + if t.cell.is_some() { + return Err(bad(id, "notes go in story text")); + } + let (at, note) = st.doc.story(t.story).and_then(|x| neighbor(x, &t, forward)).ok_or_else(|| bad(id, no_note(forward)))?; + let pos = at + NOTE_MARK.len_utf8(); + st.selection.text = Some(designcraft_doc::TextSel::caret(t.story, pos)); + st.bump_revision(); + s.typing_format = None; + Ok(json!({"story": t.story.0, "id": note, "pos": pos})) +} + #[cfg(test)] mod tests { use serde_json::json; @@ -136,6 +201,46 @@ mod tests { assert!(s.doc().unwrap().doc.stories[&sid].editorial.is_empty()); } + #[test] + fn previous_and_next_note_step_through_the_story_s_notes() { + 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": "One two three."})).unwrap(); + let len = designcraft_doc::NOTE_MARK.len_utf8(); + let caret = |s: &mut Session, at: usize| s.execute("text.select", &json!({"story": r["story"], "anchor": at, "focus": at})).unwrap(); + caret(&mut s, 3); + let first = s.execute("note.new", &json!({"text": "a"})).unwrap(); + caret(&mut s, 7 + len); + let second = s.execute("note.new", &json!({"text": "b"})).unwrap(); + caret(&mut s, 0); + let disabled = |s: &mut Session, id: &str| matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))); + assert!(disabled(&mut s, "note.previous"), "no note before the start"); + let n = s.execute("note.next", &json!({})).unwrap(); + assert_eq!((n["id"].clone(), n["pos"].clone()), (first["id"].clone(), json!(3 + len))); + let n = s.execute("note.next", &json!({})).unwrap(); + assert_eq!((n["id"].clone(), n["pos"].clone()), (second["id"].clone(), json!(7 + 2 * len))); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (7 + 2 * len, 7 + 2 * len), "the caret follows the anchor"); + assert!(disabled(&mut s, "note.next"), "no note after the last"); + // From just after the second anchor, Previous Note goes to the first one. + let n = s.execute("note.previous", &json!({})).unwrap(); + assert_eq!(n["id"], first["id"]); + assert!(disabled(&mut s, "note.previous")); + // From the middle of the story, each direction finds its neighbor. + caret(&mut s, 5 + len); + assert_eq!(s.execute("note.previous", &json!({})).unwrap()["id"], first["id"]); + caret(&mut s, 5 + len); + assert_eq!(s.execute("note.next", &json!({})).unwrap()["id"], second["id"]); + // Formatting set at the insertion point does not survive the move. + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + assert!(s.typing_format.is_some()); + s.execute("note.previous", &json!({})).unwrap(); + assert!(s.typing_format.is_none(), "note.previous"); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("note.next", &json!({})).unwrap(); + assert!(s.typing_format.is_none(), "note.next"); + } + #[test] fn notes_insert_into_a_story_whose_markers_lack_payloads() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e74cc0f73..da24464f7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2084,6 +2084,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("New Table Style…", ["New Table Style…", "Nouveau style de tableau…", "New Table Style…", "New Table Style…", "新建表样式…"]), ("Next Cell", ["Next Cell", "Cellule suivante", "Next Cell", "Next Cell", "下一个单元格"]), ("Next Document", ["Next Document", "Document suivant", "Next Document", "Next Document", "下一个文档"]), + ("Next Note", ["Next Note", "Note suivante", "Next Note", "Next Note", "下一个注释"]), ("Next Object Above", ["Next Object Above", "Objet suivant au-dessus", "Next Object Above", "Next Object Above", "上方下一个对象"]), ("Next Object Below", ["Next Object Below", "Objet suivant en dessous", "Next Object Below", "Next Object Below", "下方下一个对象"]), ( @@ -2162,6 +2163,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Preflight Document", ["Preflight Document", "Contrôle en amont du document", "Preflight Document", "Preflight Document", "预检文档"]), ("Previous Cell", ["Previous Cell", "Cellule précédente", "Previous Cell", "Previous Cell", "上一个单元格"]), ("Previous Document", ["Previous Document", "Document précédent", "Previous Document", "Previous Document", "上一个文档"]), + ("Previous Note", ["Previous Note", "Note précédente", "Previous Note", "Previous Note", "上一个注释"]), ( "Previous Object in Group", ["Previous Object in Group", "Objet précédent du groupe", "Previous Object in Group", "Previous Object in Group", "组中的上一个对象"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 49fd3046e..281a9b9f7 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1481,6 +1481,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "نمط جدول جديد…"), ("Next Cell", "الخلية التالية"), ("Next Document", "المستند التالي"), + ("Next Note", "الملاحظة التالية"), ("Next Object Above", "الكائن التالي أعلى"), ("Next Object Below", "الكائن التالي أسفل"), ("Next Object in Group", "الكائن التالي في المجموعة"), @@ -1509,6 +1510,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "فحص المستند قبل الطباعة"), ("Previous Cell", "الخلية السابقة"), ("Previous Document", "المستند السابق"), + ("Previous Note", "الملاحظة السابقة"), ("Previous Object in Group", "الكائن السابق في المجموعة"), ("Printers", "طابعات"), ("Proof Colors", "ألوان المعاينة الطباعية"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 6f47fdaff..c47b4cb12 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -632,6 +632,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Nový styl tabulky…"), ("Next Cell", "Další buňka"), ("Next Document", "Další dokument"), + ("Next Note", "Další poznámka"), ("Next Object Above", "Další objekt nad"), ("Next Object Below", "Další objekt pod"), ("Next Object in Group", "Další objekt ve skupině"), @@ -665,6 +666,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Předtisková kontrola dokumentu"), ("Previous Cell", "Předchozí buňka"), ("Previous Document", "Předchozí dokument"), + ("Previous Note", "Předchozí poznámka"), ("Previous Object in Group", "Předchozí objekt ve skupině"), ("Primary Text Frame", "Primární textový rámeček"), ("Print Booklet…", "Tisk brožury…"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 97b48316b..4d056bae5 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -669,6 +669,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Neues Tabellenformat …"), ("Next Cell", "Nächste Zelle"), ("Next Document", "Nächstes Dokument"), + ("Next Note", "Nächste Notiz"), ("Next Object Above", "Nächstes Objekt darüber"), ("Next Object Below", "Nächstes Objekt darunter"), ("Next Object in Group", "Nächstes Objekt in der Gruppe"), @@ -702,6 +703,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Preflight für Dokument"), ("Previous Cell", "Vorherige Zelle"), ("Previous Document", "Vorheriges Dokument"), + ("Previous Note", "Vorherige Notiz"), ("Previous Object in Group", "Vorheriges Objekt in der Gruppe"), ("Primary Text Frame", "Primärer Textrahmen"), ("Print Booklet…", "Broschüre drucken …"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 4a3656699..e074076a1 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -760,6 +760,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Nuovo stile tabella…"), ("Next Cell", "Cella successiva"), ("Next Document", "Documento successivo"), + ("Next Note", "Nota successiva"), ("Next Object Above", "Oggetto successivo sopra"), ("Next Object Below", "Oggetto successivo sotto"), ("Next Object in Group", "Oggetto successivo nel gruppo"), @@ -793,6 +794,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Verifica preliminare documento"), ("Previous Cell", "Cella precedente"), ("Previous Document", "Documento precedente"), + ("Previous Note", "Nota precedente"), ("Previous Object in Group", "Oggetto precedente nel gruppo"), ("Primary Text Frame", "Cornice di testo principale"), ("Print Booklet…", "Stampa opuscolo…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 6180858af..5972340eb 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -861,6 +861,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "新規表スタイル…"), ("Next Cell", "次のセル"), ("Next Document", "次のドキュメント"), + ("Next Note", "次の注釈"), ("Next Object Above", "1つ前面のオブジェクト"), ("Next Object Below", "1つ背面のオブジェクト"), ("Next Object in Group", "グループ内の次のオブジェクト"), @@ -894,6 +895,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "ドキュメントのプリフライト"), ("Previous Cell", "前のセル"), ("Previous Document", "前のドキュメント"), + ("Previous Note", "前の注釈"), ("Previous Object in Group", "グループ内の前のオブジェクト"), ("Primary Text Frame", "主テキストフレーム"), ("Print Booklet…", "小冊子をプリント…"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 4767b6c30..a3e9df769 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -688,6 +688,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "ცხრილის ახალი სტილი…"), ("Next Cell", "შემდეგი უჯრა"), ("Next Document", "შემდეგი დოკუმენტი"), + ("Next Note", "შემდეგი შენიშვნა"), ("Next Object Above", "შემდეგი ობიექტი ზემოთ"), ("Next Object Below", "შემდეგი ობიექტი ქვემოთ"), ("Next Object in Group", "ჯგუფის შემდეგი ობიექტი"), @@ -721,6 +722,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "დოკუმენტის წინასწარი შემოწმება"), ("Previous Cell", "წინა უჯრა"), ("Previous Document", "წინა დოკუმენტი"), + ("Previous Note", "წინა შენიშვნა"), ("Previous Object in Group", "ჯგუფის წინა ობიექტი"), ("Primary Text Frame", "ძირითადი ტექსტური ჩარჩო"), ("Print Booklet…", "ბროშურის დაბეჭდვა…"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index bf0c7600b..4b694aa35 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -639,6 +639,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Nowy styl tabeli…"), ("Next Cell", "Następna komórka"), ("Next Document", "Następny dokument"), + ("Next Note", "Następna notatka"), ("Next Object Above", "Następny obiekt powyżej"), ("Next Object Below", "Następny obiekt poniżej"), ("Next Object in Group", "Następny obiekt w grupie"), @@ -672,6 +673,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Inspekcja wstępna dokumentu"), ("Previous Cell", "Poprzednia komórka"), ("Previous Document", "Poprzedni dokument"), + ("Previous Note", "Poprzednia notatka"), ("Previous Object in Group", "Poprzedni obiekt w grupie"), ("Primary Text Frame", "Główna ramka tekstowa"), ("Print Booklet…", "Drukuj broszurę…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 59c4f2e23..d6e668bed 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1614,6 +1614,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Novo estilo de tabela…"), ("Next Cell", "Próxima célula"), ("Next Document", "Próximo documento"), + ("Next Note", "Próxima nota"), ("Next Object Above", "Próximo objeto acima"), ("Next Object Below", "Próximo objeto abaixo"), ("Next Object in Group", "Próximo objeto no grupo"), @@ -1641,6 +1642,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Verificar documento (preflight)"), ("Previous Cell", "Célula anterior"), ("Previous Document", "Documento anterior"), + ("Previous Note", "Nota anterior"), ("Previous Object in Group", "Objeto anterior no grupo"), ("Printers", "Impressoras"), ("Proof Colors", "Cores de prova"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 412faea7e..bbf5bfeaf 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -619,6 +619,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Новый стиль таблицы…"), ("Next Cell", "Следующая ячейка"), ("Next Document", "Следующий документ"), + ("Next Note", "Следующая заметка"), ("Next Object Above", "Следующий объект выше"), ("Next Object Below", "Следующий объект ниже"), ("Next Object in Group", "Следующий объект в группе"), @@ -652,6 +653,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Предпечатная проверка документа"), ("Previous Cell", "Предыдущая ячейка"), ("Previous Document", "Предыдущий документ"), + ("Previous Note", "Предыдущая заметка"), ("Previous Object in Group", "Предыдущий объект в группе"), ("Primary Text Frame", "Основная текстовая рамка"), ("Print Booklet…", "Печать буклета…"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 62c594fd1..4c016860a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -763,6 +763,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Новий стиль таблиці…"), ("Next Cell", "Наступна клітинка"), ("Next Document", "Наступний документ"), + ("Next Note", "Наступна примітка"), ("Next Object Above", "Наступний об’єкт вище"), ("Next Object Below", "Наступний об’єкт нижче"), ("Next Object in Group", "Наступний об’єкт у групі"), @@ -796,6 +797,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Перевірити документ перед друком"), ("Previous Cell", "Попередня клітинка"), ("Previous Document", "Попередній документ"), + ("Previous Note", "Попередня примітка"), ("Previous Object in Group", "Попередній об’єкт у групі"), ("Primary Text Frame", "Основний текстовий фрейм"), ("Print Booklet…", "Друк буклета…"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 75053ef1b..51be71818 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -499,6 +499,9 @@ pub const MENUS: &[(&str, &[&str])] = &[ ">Notes", "cmd:note.new", "ui:window.panel|Notes|{\"panel\": \"notes\"}", + "-", + "cmd:note.previous", + "cmd:note.next", "<", ">Track Changes", "cmd:changes.track", From 7848c2122099c13301efe0d091105aa096707966 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:38:37 -0400 Subject: [PATCH 33/71] Paragraph Style Options: Align to Grid (None, All Lines, First Line Only) in Indents and Spacing (#1759) --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index c997b9e35..caec23486 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3a1818f91..e9ad8ca60 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3981,6 +3981,26 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } ui.end_row(); } + // The CJK Grid Settings section edits the same `p.gridAlign` field. + use designcraft_doc::GridAlign; + const GRID_ALIGN: [(GridAlign, &str); 3] = + [(GridAlign::None, "None"), (GridAlign::AllLines, "All Lines"), (GridAlign::FirstLineOnly, "First Line Only")]; + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align to Grid:")); + let g: GridAlign = serde_json::from_value(cur(d, "p.gridAlign", &pv["gridAlign"])).unwrap_or_default(); + let shown = GRID_ALIGN.iter().find(|o| o.0 == g).map_or("", |o| o.1); + egui::ComboBox::from_id_salt("psgridalign") + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, shown))) + .width(240.0) + .show_ui(ui, |ui| { + for (v, label) in GRID_ALIGN { + if ui.selectable_label(v == g, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() + && let Ok(v) = serde_json::to_value(v) + { + d.fields.insert("p.gridAlign".into(), v); + } + } + }); + ui.end_row(); }); } "tabs" => style_tabs(&app.ui.language, ui, d, &pv, &doc), @@ -9658,6 +9678,31 @@ mod tests { assert_eq!(st.doc.styles.para(&name).unwrap().para.align, Some(designcraft_doc::Align::Center)); } + #[test] + fn paragraph_style_options_align_to_grid_shows_and_sets_the_style_grid_alignment() { + use designcraft_doc::GridAlign; + use egui_kittest::kittest::Queryable; + let mut app = test_app(); + app.session.execute("style.paragraph.create", &json!({"name": "Grid Body", "para": {"gridAlign": "firstLineOnly"}})).unwrap(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Grid Body", "section": "indents"}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + let grid = |h: &egui_kittest::Harness<'static, crate::test_window::Window>| { + h.state().app.session.active().unwrap().doc.styles.para("Grid Body").unwrap().para.grid_align + }; + h.run_steps(4); + h.get_by_value("First Line Only"); + confirm(&mut h.state_mut().app).unwrap(); + assert_eq!(grid(&h), Some(GridAlign::FirstLineOnly), "OK without edits keeps the style's alignment"); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Grid Body", "section": "indents"}))); + h.run_steps(4); + h.get_by_value("First Line Only").click(); + h.run_steps(3); + h.get_by_label("All Lines").click(); + h.run_steps(3); + confirm(&mut h.state_mut().app).unwrap(); + assert_eq!(grid(&h), Some(GridAlign::AllLines)); + } + #[test] fn delete_paragraph_style_dialog_removes_the_style_and_reassigns_text() { let mut app = test_app(); From d767fcf41e4e8813039933529180f4614a2e8ac8 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:55:47 -0400 Subject: [PATCH 34/71] Text frames: a custom baseline grid's Relative To (page, margin, frame or inset) (#1758) --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 16 ++++- crates/compose/src/tests.rs | 33 +++++++++- crates/doc/src/item.rs | 98 +++++++++++++++++++++++++---- crates/engine/src/cmd/object.rs | 78 ++++++++++++++++++++++- crates/idml/src/export.rs | 9 +++ crates/idml/src/import.rs | 14 +++++ crates/idml/src/tests/frame_grid.rs | 31 ++++++++- crates/ui-egui/src/dialogs.rs | 86 +++++++++++++++++++++++++ docs/typography-parity.md | 2 +- 10 files changed, 351 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index caec23486..3eff9dc1c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change. - **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index fc166167d..687f4c7ea 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -731,9 +731,23 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId let under = spread.and_then(|sp| sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi))); let page_rect = under.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))); let g = &doc.settings.baseline_grid; - let (start, inc) = tf.options.baseline_grid.map_or((under.map_or(g.start, |pg| g.origin(pg)), g.increment), |f| (f.start, f.increment)); // Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only). let ty = xf.translation().y; + let (start, inc) = match tf.options.baseline_grid { + None => (under.map_or(g.start, |pg| g.origin(pg)), g.increment), + Some(f) => { + use designcraft_doc::FrameGridRelative as R; + // The frame's own edges are in inner space; `ty` takes them to spread y as it takes + // the grid back, so a frame-relative grid keeps its place in a turned frame. + let from = match f.relative_to { + R::TopOfPage => 0.0, + R::TopMargin => under.map_or(0.0, |pg| pg.margins.top), + R::TopOfFrame => ty + item.inner_bounds().y0, + R::TopInset => ty + item.text_area().y0, + }; + (from + f.start, f.increment) + } + }; let grid = (inc > 0.0 && inc.is_finite() && start.is_finite()).then_some((start - ty, inc)); // Type on a path: one line as long as the path (from the start offset). let (area, opts) = match &tf.options.path { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 444e34ebb..cd4783520 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -6697,7 +6697,7 @@ fn roman_word_break_breaks_inside_words_without_hyphens() { fn grid_doc(text: &str, para: ParaAttrs, edit: impl FnOnce(&mut Document, StoryId)) -> (Vec, (f64, f64)) { let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), para); if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { - tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid { start: 0.0, increment: 12.0 }); + tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(0.0, 12.0)); } edit(&mut d, sid); // Vertical frames have no baseline grid. @@ -7407,6 +7407,37 @@ fn baseline_grid_relative_to_the_top_margin_starts_at_each_pages_margin() { assert_eq!(lines.len(), 41); } +/// A frame's custom grid (start 10, increment 13) measured from the page top, the top margin +/// (36), the frame top (100) or the top inset (6 below it). +#[test] +fn frame_baseline_grid_relative_to_page_margin_frame_or_inset() { + use designcraft_doc::{FrameBaselineGrid, FrameGridRelative as R}; + let first_baseline = |relative_to: Option| { + let mut d = Document::new(&NewDocument { margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() }); + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 100.0, 336.0, 400.0), lid, "Grid", para).unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].chars = + designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() }; + let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap(); + tf.options.inset = [6.0, 0.0, 0.0, 0.0]; + tf.options.baseline_grid = relative_to.map(|r| FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(10.0, 13.0) }); + // A document grid that matches none of the frame grids. + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 0.0; + compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline + }; + // Without a grid the first baseline sits an ascent below the top inset. + let natural = first_baseline(None); + assert!(natural > 106.0 && natural < 120.0, "{natural}"); + for (r, origin) in [(R::TopOfPage, 10.0), (R::TopMargin, 46.0), (R::TopOfFrame, 110.0), (R::TopInset, 116.0)] { + let want = (0..).map(|n| origin + 13.0 * f64::from(n)).find(|y| *y >= natural - 1e-9).unwrap(); + let got = first_baseline(Some(r)); + assert!((got - want).abs() < 1e-6, "{r:?}: first baseline {got}, want {want} (natural {natural})"); + } +} + /// A group's transform moves a frame from the left page of a spread to the right one: the grid /// starts at the right page's top margin. #[test] diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index ab345e371..31d3a08d2 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -458,25 +458,87 @@ pub struct TextFrameOptions { pub path: Option, } -/// A text frame's custom baseline grid. Saved as `[start, increment]`. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(from = "(f64, f64)", into = "(f64, f64)")] +/// A text frame's custom baseline grid. Saved as `[start, increment]` when it starts from the +/// top of the page, otherwise as `[start, increment, relativeTo]`. +#[derive(Clone, Copy, Debug, PartialEq)] pub struct FrameBaselineGrid { - /// The first grid line, down from the top of the page. + /// The first grid line, down from the line `relative_to` names. pub start: f64, /// The distance between grid lines; 0 or less turns the grid off. pub increment: f64, + pub relative_to: FrameGridRelative, } -impl From<(f64, f64)> for FrameBaselineGrid { - fn from((start, increment): (f64, f64)) -> Self { - FrameBaselineGrid { start, increment } +impl FrameBaselineGrid { + /// A grid measured from the top of the page. + pub fn new(start: f64, increment: f64) -> Self { + FrameBaselineGrid { start, increment, relative_to: FrameGridRelative::TopOfPage } } } -impl From for (f64, f64) { - fn from(g: FrameBaselineGrid) -> Self { - (g.start, g.increment) +/// What a frame's custom baseline grid's start is measured from. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FrameGridRelative { + #[default] + #[serde(rename = "page")] + TopOfPage, + #[serde(rename = "margin")] + TopMargin, + #[serde(rename = "frame")] + TopOfFrame, + #[serde(rename = "inset")] + TopInset, +} + +impl FrameGridRelative { + pub const ALL: [FrameGridRelative; 4] = + [FrameGridRelative::TopOfPage, FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset]; + + /// The name commands and native files use: page, margin, frame or inset. + pub fn name(self) -> &'static str { + match self { + FrameGridRelative::TopOfPage => "page", + FrameGridRelative::TopMargin => "margin", + FrameGridRelative::TopOfFrame => "frame", + FrameGridRelative::TopInset => "inset", + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|r| r.name() == name) + } +} + +impl Serialize for FrameBaselineGrid { + fn serialize(&self, s: S) -> Result { + // Files saved before Relative To existed hold two elements; a page-relative grid keeps that form. + match self.relative_to { + FrameGridRelative::TopOfPage => (self.start, self.increment).serialize(s), + r => (self.start, self.increment, r).serialize(s), + } + } +} + +impl<'de> Deserialize<'de> for FrameBaselineGrid { + fn deserialize>(d: D) -> Result { + struct Grid; + impl<'de> serde::de::Visitor<'de> for Grid { + type Value = FrameBaselineGrid; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("[start, increment] or [start, increment, relativeTo]") + } + fn visit_seq>(self, mut a: A) -> Result { + use serde::de::Error; + let start = a.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?; + let increment = a.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?; + let relative_to = a.next_element()?.unwrap_or_default(); + if a.next_element::()?.is_some() { + return Err(A::Error::invalid_length(4, &self)); + } + Ok(FrameBaselineGrid { start, increment, relative_to }) + } + } + d.deserialize_seq(Grid) } } @@ -1686,10 +1748,24 @@ mod tests { #[test] fn frame_baseline_grid_saves_as_start_then_increment() { + // A file saved before Relative To existed: the grid starts from the top of the page and + // saves back unchanged. let v = serde_json::json!({"baselineGrid": [6.0, 14.0]}); let o = serde_json::from_value::(v).unwrap(); - assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0 })); + assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0, relative_to: FrameGridRelative::TopOfPage })); assert_eq!(serde_json::to_value(&o).unwrap()["baselineGrid"], serde_json::json!([6.0, 14.0])); + for r in [FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset] { + let o = TextFrameOptions { + baseline_grid: Some(FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(6.0, 14.0) }), + ..Default::default() + }; + let v = serde_json::to_value(&o).unwrap(); + assert_eq!(v["baselineGrid"], serde_json::json!([6.0, 14.0, r.name()])); + assert_eq!(serde_json::from_value::(v).unwrap().baseline_grid, o.baseline_grid); + } + for bad in [serde_json::json!([6.0]), serde_json::json!([6.0, 14.0, "frame", 1]), serde_json::json!([6.0, 14.0, "spine"])] { + assert!(serde_json::from_value::(serde_json::json!({"baselineGrid": bad})).is_err()); + } } #[test] diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 87f601604..84881b067 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -598,7 +598,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -2506,6 +2506,41 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { as u8, ), }; + // `Some(None)` turns the frame's custom baseline grid off. + let custom_grid = match p.get("baselineGrid") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::Object(g)) => { + let num = |key: &str, lo: f64, hi: f64| match g.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (lo..=hi).contains(&x) => Ok(Some(x)), + _ => Err(bad(ID, format!("baselineGrid.{key} must be a number of points from {lo} to {hi}, not {v}"))), + }, + }; + let start = num("start", -15552.0, 15552.0)?; + let increment = num("increment", 0.0, 15552.0)?; + let relative_to = match g.get("relativeTo") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_str() + .and_then(designcraft_doc::FrameGridRelative::from_name) + .ok_or_else(|| bad(ID, format!("baselineGrid.relativeTo must be page|margin|frame|inset, not {v}")))?, + ), + }; + Some(Some((start, increment, relative_to))) + } + Some(v) => return Err(bad(ID, format!("baselineGrid must be an object or null, not {v}"))), + }; + // A frame without a custom grid starts from the document's. + let doc_grid = { + let g = &st.doc.settings.baseline_grid; + let relative_to = match g.relative_to { + designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage, + designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin, + }; + designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to } + }; let rule_color = match p.get("columnRuleColor") { None | Some(Value::Null) => None, Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()), @@ -2596,6 +2631,17 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = rule_bottom { o.column_rule_bottom_inset = v; } + match custom_grid { + Some(Some((start, increment, relative_to))) => { + let mut g = o.baseline_grid.unwrap_or(doc_grid); + g.start = start.unwrap_or(g.start); + g.increment = increment.unwrap_or(g.increment); + g.relative_to = relative_to.unwrap_or(g.relative_to); + o.baseline_grid = Some(g); + } + Some(None) => o.baseline_grid = None, + None => {} + } // Fixed Width columns set the frame's width: n columns, the gutters between them and // the left and right insets. let fixed_width = o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth && o.column_width > 0.0; @@ -5609,3 +5655,33 @@ mod fill_tint_tests { assert!(stroke_of(&s, b).is_none()); } } + +#[cfg(test)] +mod frame_baseline_grid_tests { + use super::*; + use designcraft_doc::{FrameBaselineGrid, FrameGridRelative}; + + #[test] + fn text_frame_options_set_a_custom_baseline_grid_relative_to() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let grid = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).and_then(|t| t.options.baseline_grid); + // Turning it on with only Relative To takes the document grid's start and increment. + let doc = s.doc().unwrap().doc.settings.baseline_grid.clone(); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "frame"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: doc.start, increment: doc.increment, relative_to: FrameGridRelative::TopOfFrame })); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"start": 10, "increment": 13, "relativeTo": "inset"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopInset })); + // Fields not given keep the frame's. + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "margin"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopMargin })); + for bad in [json!({"relativeTo": "spine"}), json!({"start": f64::MAX}), json!({"increment": -1}), json!(3)] { + assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": bad})).is_err(), "{bad}"); + } + assert_eq!(grid(&s).map(|g| g.relative_to), Some(FrameGridRelative::TopMargin)); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": null})).unwrap(); + assert_eq!(grid(&s), None); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 6bc4d30ca..0106364e2 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1949,6 +1949,15 @@ impl<'a> Ex<'a> { El::new("BaselineFrameGridOption") .attr("UseCustomBaselineFrameGrid", "true") .attr("StartingOffsetForBaselineFrameGrid", num(g.start)) + .attr( + "BaselineFrameGridRelativeOption", + match g.relative_to { + designcraft_doc::FrameGridRelative::TopOfPage => "TopOfPageOfBaselineFrameGridRelativeOption", + designcraft_doc::FrameGridRelative::TopMargin => "TopOfMarginOfBaselineFrameGridRelativeOption", + designcraft_doc::FrameGridRelative::TopOfFrame => "TopOfFrameOfBaselineFrameGridRelativeOption", + designcraft_doc::FrameGridRelative::TopInset => "TopOfInsetOfBaselineFrameGridRelativeOption", + }, + ) .attr("BaselineFrameGridIncrement", num(g.increment)), ); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 3dbb67901..77d730fa0 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2977,6 +2977,7 @@ impl<'r> Importer<'r> { options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid { start: g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), increment: g.num("BaselineFrameGridIncrement").unwrap_or(12.0), + relative_to: g.get("BaselineFrameGridRelativeOption").map(frame_grid_relative).unwrap_or_default(), }); } it.content = Content::Text(TextFrame { story, options }); @@ -3518,6 +3519,19 @@ fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Colu columns } +/// A `BaselineFrameGridRelativeOption` value (`TopOfInsetOfBaselineFrameGridRelativeOption`, or a +/// short form such as `TopOfInset`); unknown values measure from the top of the page. +fn frame_grid_relative(v: &str) -> designcraft_doc::FrameGridRelative { + use designcraft_doc::FrameGridRelative as R; + let v = v.strip_suffix("OfBaselineFrameGridRelativeOption").unwrap_or(v); + match v { + "TopOfMargin" | "TopMargin" => R::TopMargin, + "TopOfFrame" => R::TopOfFrame, + "TopOfInset" | "TopInset" => R::TopInset, + _ => R::TopOfPage, + } +} + /// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it. fn text_frame_options(prefs: &[&El]) -> TextFrameOptions { let e = Prefs(prefs); diff --git a/crates/idml/src/tests/frame_grid.rs b/crates/idml/src/tests/frame_grid.rs index 93c081377..99f57b4ea 100644 --- a/crates/idml/src/tests/frame_grid.rs +++ b/crates/idml/src/tests/frame_grid.rs @@ -6,6 +6,12 @@ const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/pack /// 14 pt increment; its four paragraphs align to the grid. The frame's top is the page's top, so /// the grid lines are 6 + n·14 pt down from the frame top whatever the grid is measured from. fn frame_grid_doc() -> Document { + frame_grid_doc_relative(None) +} + +/// [`frame_grid_doc`] with `BaselineFrameGridRelativeOption` set to `relative` when given. +fn frame_grid_doc_relative(relative: Option<&str>) -> Document { + let relative = relative.map(|r| format!(r#" BaselineFrameGridRelativeOption="{r}""#)).unwrap_or_default(); let designmap = format!( r#" "# @@ -17,7 +23,7 @@ fn frame_grid_doc() -> Document { .collect(); let spread = format!( r#" -{pts} +{pts} "# ); let story = format!( @@ -46,7 +52,7 @@ fn baselines(d: &Document) -> Vec { fn custom_frame_grid_sets_lines_from_its_start_by_its_increment() { let d = frame_grid_doc(); let grid = d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid; - assert_eq!(grid, Some(designcraft_doc::FrameBaselineGrid { start: 6.0, increment: 14.0 })); + assert_eq!(grid, Some(designcraft_doc::FrameBaselineGrid::new(6.0, 14.0))); let b = baselines(&d); assert_eq!(b.len(), 4, "{b:?}"); for y in &b { @@ -69,3 +75,24 @@ fn custom_frame_grid_round_trips() { assert_eq!(grid(&back), grid(&d)); assert_eq!(baselines(&back), baselines(&d)); } + +#[test] +fn custom_frame_grid_relative_to_round_trips() { + use designcraft_doc::FrameGridRelative as R; + let relative = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid.map(|g| g.relative_to); + for (attr, want) in [ + ("TopOfPageOfBaselineFrameGridRelativeOption", R::TopOfPage), + ("TopOfMarginOfBaselineFrameGridRelativeOption", R::TopMargin), + ("TopOfFrameOfBaselineFrameGridRelativeOption", R::TopOfFrame), + ("TopOfInsetOfBaselineFrameGridRelativeOption", R::TopInset), + ("TopOfInset", R::TopInset), + ("SomethingNew", R::TopOfPage), + ] { + let d = frame_grid_doc_relative(Some(attr)); + assert_eq!(relative(&d), Some(want), "{attr}"); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(relative(&back), Some(want), "{attr} after export"); + } + // A frame without the attribute measures from the top of the page. + assert_eq!(relative(&frame_grid_doc()), Some(R::TopOfPage)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e9ad8ca60..5245e00be 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -527,6 +527,15 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { _ => ((inner - o.gutter * (n - 1.0)) / n).max(0.0), }; let kind = |v: Value| v.as_str().unwrap_or_default().to_string(); + // A frame without a custom baseline grid shows the document grid's start and increment. + let grid = o.baseline_grid.unwrap_or_else(|| { + let g = app.session.active().map(|s| s.doc.settings.baseline_grid.clone()).unwrap_or_default(); + let relative_to = match g.relative_to { + designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage, + designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin, + }; + designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to } + }); let shown = json!({ "columns": o.columns, "gutter": fm(o.gutter), "columnsKind": kind(serde_json::to_value(o.columns_kind).unwrap_or_default()), "columnWidth": fm(width), @@ -542,6 +551,8 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { "useMinimumHeight": o.auto_size_use_min_height, "minimumHeight": fm(o.auto_size_min_height), "useMinimumWidth": o.auto_size_use_min_width, "minimumWidth": fm(o.auto_size_min_width), "noLineBreaks": o.auto_size_no_line_breaks, + "customGrid": o.baseline_grid.is_some(), "gridStart": fm(grid.start), "gridIncrement": fm(grid.increment), + "gridRelativeTo": grid.relative_to.name(), }); if let Value::Object(m) = &shown { for (k, v) in m { @@ -593,6 +604,28 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) text_field(ui, d, "firstBaselineMin", 70.0); ui.end_row(); }); + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(tr("Baseline Grid")).font(semibold(12.0))); + check(ui, d, "customGrid", tr("Use Custom Baseline Grid")); + ui.add_enabled_ui(d.b("customGrid"), |ui| { + egui::Grid::new("tfo_grid").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Start:")); + text_field(ui, d, "gridStart", 70.0); + ui.end_row(); + crate::rtl::label(ui, tr("Relative To:")); + translated_combo( + &lang, + ui, + d, + "gridRelativeTo", + &[("page", "Top of Page"), ("margin", "Top Margin"), ("frame", "Top of Frame"), ("inset", "Top Inset")], + ); + ui.end_row(); + crate::rtl::label(ui, tr("Increment Every:")); + text_field(ui, d, "gridIncrement", 70.0); + ui.end_row(); + }); + }); } "autosize" => auto_size(&lang, ui, d), "rules" => column_rules(app, ui, d), @@ -857,6 +890,30 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result= 0.0).ok_or_else(|| bad(what))?)); } } + // The custom baseline grid: off, or the fields the user changed (all of them when it is + // turned on, since it shows the document grid's). + let grid_keys = ["customGrid", "gridStart", "gridIncrement", "gridRelativeTo"]; + if grid_keys.iter().any(|k| edited(d, k)) { + if d.b("customGrid") { + let all = edited(d, "customGrid"); + let mut g = Map::new(); + if all || edited(d, "gridStart") { + g.insert("start".into(), json!(d.m("gridStart").ok_or_else(|| bad("the baseline grid start is not a measure"))?)); + } + if all || edited(d, "gridIncrement") { + g.insert( + "increment".into(), + json!(d.m("gridIncrement").filter(|v| *v > 0.0).ok_or_else(|| bad("the baseline grid increment is not a measure"))?), + ); + } + if all || edited(d, "gridRelativeTo") { + g.insert("relativeTo".into(), json!(d.s("gridRelativeTo"))); + } + p.insert("baselineGrid".into(), Value::Object(g)); + } else { + p.insert("baselineGrid".into(), Value::Null); + } + } if p.is_empty() { return Ok(Value::Null); } @@ -8323,6 +8380,35 @@ mod tests { assert_eq!((opts(&app).columns_kind, opts(&app).column_width), (designcraft_doc::ColumnsKind::FixedNumber, 120.0)); } + #[test] + fn text_frame_options_set_a_custom_baseline_grid_relative_to() { + use designcraft_doc::{FrameBaselineGrid, FrameGridRelative}; + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let grid = |app: &DesignApp| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.baseline_grid; + let open = |app: &mut DesignApp, fields: &[(&str, Value)]| { + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(app, &mut d); + for (k, v) in fields { + d.fields.insert((*k).into(), v.clone()); + } + app.ui.dialog = Some(d); + confirm(app) + }; + app.run("selection.set", json!({"ids": [id.0]})).unwrap(); + // Turned on, the grid takes the shown start and increment (the document grid's: 3p0 and 1p0). + open(&mut app, &[("customGrid", json!(true)), ("gridRelativeTo", json!("frame"))]).unwrap(); + assert_eq!(grid(&app), Some(FrameBaselineGrid { start: 36.0, increment: 12.0, relative_to: FrameGridRelative::TopOfFrame })); + // The dialog shows the frame's grid; changing Relative To keeps its start and increment. + open(&mut app, &[("gridStart", json!("0p10"))]).unwrap(); + open(&mut app, &[("gridRelativeTo", json!("inset"))]).unwrap(); + assert_eq!(grid(&app), Some(FrameBaselineGrid { start: 10.0, increment: 12.0, relative_to: FrameGridRelative::TopInset })); + open(&mut app, &[("customGrid", json!(false))]).unwrap(); + assert_eq!(grid(&app), None); + } + #[test] fn text_frame_options_change_only_what_was_edited() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 12a9dd071..9d3f7d4d0 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -46,7 +46,7 @@ Fidelity: **unmeasured** unless stated. | Leading | auto (120%), absolute, leading model | ✓ | ✓ | CJK leading models in cjk-typography.md | | First baseline | ascent, cap height, x-height, leading, fixed, min | ✓ | ✓ | | | Frame fit / overset | baseline of the last line must fit | descenders must fit | ~ | **differs** (#180): frames go overset where InDesign fits | -| Baseline grid | document grid relative to the top of the page or each page's top margin; frame grids | ✓ | ✓ | | +| Baseline grid | document grid relative to the top of the page or each page's top margin; frame grids relative to the top of the page, top margin, top of frame or top inset | ✓ | ✓ | | | Vertical justification | top, centre, bottom, justify, paragraph spacing limit | ✓ | ✓ | | | Text in shaped frames | lines follow oval, polygon and freeform frame outlines, one inset from the outline | ✓ | ~ | concave shapes set one slot per line (the widest); vertical justification and paragraph shading use the frame's rectangle (#646) | | Columns, span / split columns | ✓ | ✓ | ✓ | split columns within a column (#295); text after a span flows below it (#276) | From 5fca302ff6e0ba026321a626ca44e3c0ef620dc7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 24 Oct 2026 10:01:04 -0400 Subject: [PATCH 35/71] Endnotes: saturate the remaining numbering sites at u32::MAX Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 4 ++-- crates/doc/src/endnotes.rs | 2 +- crates/engine/src/cmd/endnotes.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index a3fac1eab..48fb2d90e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1017,9 +1017,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; if !story.endnotes.is_empty() { // Endnote reference numbers continue through the document. - let first = doc.endnote_start(story.id) + story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32; + let first = doc.endnote_start(story.id).saturating_add(story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32); for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::ENDNOTE_REF).enumerate() { - sub.notes.insert(prange.start + i, doc.endnote_options.label(first + k as u32)); + sub.notes.insert(prange.start + i, doc.endnote_options.label(first.saturating_add(k as u32))); } } if !story.objects.is_empty() { diff --git a/crates/doc/src/endnotes.rs b/crates/doc/src/endnotes.rs index 0d54d2379..b15bb745c 100644 --- a/crates/doc/src/endnotes.rs +++ b/crates/doc/src/endnotes.rs @@ -173,7 +173,7 @@ impl crate::Document { let at = out.len(); out.insert_with(at, ¬e.text.text[r].replace('\n', " "), f.clone()); } - n += 1; + n = n.saturating_add(1); } } out diff --git a/crates/engine/src/cmd/endnotes.rs b/crates/engine/src/cmd/endnotes.rs index 7bcae744b..fb3bf64ce 100644 --- a/crates/engine/src/cmd/endnotes.rs +++ b/crates/engine/src/cmd/endnotes.rs @@ -50,7 +50,7 @@ pub fn specs() -> Vec { for st in d.stories.values().filter(|st| !st.endnotes.is_empty()) { let first = d.endnote_start(st.id); for (k, n) in st.endnotes.iter().enumerate() { - out.push(json!({"story": st.id.0, "id": n.id, "number": first + k as u32, "text": n.text.text})); + out.push(json!({"story": st.id.0, "id": n.id, "number": first.saturating_add(k as u32), "text": n.text.text})); } } out.sort_by_key(|v| v["number"].as_u64()); From 637c32506a5188311a72d7dd91c545cff7b5719e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 24 Oct 2026 10:05:52 -0400 Subject: [PATCH 36/71] GREP styles: empty matches don't count toward the per-paragraph cap Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/grep.rs | 11 +++++++++++ crates/compose/src/overlay.rs | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 09c6459e0..6f9e8f9e3 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -385,6 +385,17 @@ impl Grep { self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect() } + /// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it + /// doesn't count toward a GREP style's cap. + pub fn find_nonempty(&self, text: &str, limit: usize) -> Result>, GrepError> { + self.re + .find_iter(text) + .filter(|m| !m.as_ref().is_ok_and(|m| m.range().is_empty())) + .take(limit) + .map(|m| m.map(|m| m.range()).map_err(run_err)) + .collect() + } + /// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see /// [`translate_replacement`]). Stops after the first match when `first_only`. pub fn replacements(&self, text: &str, template: &str, first_only: bool) -> Result, String)>, GrepError> { diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index 5c95b0ec8..b09ef908e 100644 --- a/crates/compose/src/overlay.rs +++ b/crates/compose/src/overlay.rs @@ -106,7 +106,7 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], } with_pattern(&g.pattern, |slot| { let Some(re) = slot else { return }; - match re.find_all(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) { + match re.find_nonempty(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) { Ok(ms) => { if ms.len() == crate::grep::MAX_MATCHES_PER_PARAGRAPH { log::warn!("GREP style {:?} matched more than {} times in one paragraph; the rest are not styled", g.pattern, ms.len()); @@ -127,6 +127,15 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], mod tests { use super::*; + /// A pattern that can match nothing still styles a paragraph longer than the cap: empty + /// matches don't count toward it. + #[test] + fn empty_matches_do_not_use_up_the_grep_cap() { + let text = format!("{}a", "b".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 100)); + let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a*".into() }], 0); + assert_eq!(o.len(), 1, "the one non-empty match at the end is styled"); + } + /// A GREP style's scan is bounded two ways (#1598): the matches one paragraph can take, and /// the patterns kept compiled at once. #[test] From b8070d814c2b5e2a982660f9bb00c0b33326f935 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 24 Oct 2026 10:05:55 -0400 Subject: [PATCH 37/71] links: the capped-read test is native-only, like reload_capped Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/links.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 335b65b50..70ad8dac1 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -600,6 +600,7 @@ mod tests { } /// A linked file's size is the document's choice, so a link is read under a cap (#1596). + #[cfg(not(target_arch = "wasm32"))] #[test] fn a_linked_file_over_the_cap_is_refused_instead_of_read() { let dir = std::env::temp_dir().join(format!("dc-link-cap-{}", std::process::id())); From 5def4a8c01a15b8d7b20317b181e8b7d3d9d932e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:08:40 -0400 Subject: [PATCH 38/71] Story Optical Margin Alignment with a size, and Ignore Optical Margin (Story panel, engine and IDML) (#1705) * Type: story Optical Margin Alignment with a size, and Ignore Optical Margin (engine, IDML) A story's Optical Margin Alignment applies to every paragraph except those with Ignore Optical Margin; hang amounts scale with the story's size. story.options sets it, type.para and paragraph styles take ignoreOpticalMargin. IDML imports and exports StoryPreference OpticalMarginAlignment/OpticalMarginSize and IgnoreEdgeAlignment. The per-paragraph opticalMargin switch keeps hanging at the text's own size. Part of #947. * Type: the Story panel, and Ignore Optical Margin in the Paragraph panel menu and Paragraph Style Options Window > Type & Tables > Story sets Optical Margin Alignment and its size for the stories of the text selection or the selected text frames through story.options, and is disabled without either. type.selectionAttrs reports the stories' values as storyOptions, null where they differ. The Paragraph panel menu toggles ignoreOpticalMargin, and Paragraph Style Options > Indents and Spacing has an Ignore Optical Margin checkbox. Part of #947. * ROADMAP: the story Optical Margin Alignment entry names its default and scope --- ROADMAP.md | 2 + crates/compose/src/breaker.rs | 45 +++++-- crates/compose/src/lib.rs | 27 +++- crates/compose/src/tests.rs | 50 +++++++ crates/doc/src/attrs.rs | 6 + crates/doc/src/edit.rs | 20 +-- crates/doc/src/story.rs | 59 +++++++++ crates/engine/src/cmd/style.rs | 4 +- crates/engine/src/cmd/text.rs | 169 +++++++++++++++++++++++- crates/idml/src/export.rs | 8 +- crates/idml/src/import.rs | 10 ++ crates/idml/src/tests.rs | 52 ++++++++ crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/dialogs.rs | 34 +++++ crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/properties.rs | 23 +++- crates/ui-egui/src/panels/story.rs | 106 +++++++++++++++ docs/typography-parity.md | 4 +- docs/ui-parity.md | 5 +- 21 files changed, 587 insertions(+), 46 deletions(-) create mode 100644 crates/ui-egui/src/panels/story.rs diff --git a/ROADMAP.md b/ROADMAP.md index 3eff9dc1c..9299837ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Story panel (Window › Type & Tables › Story): Optical Margin Alignment and its size for the stories of the text selection or the selected text frames, disabled without either; `type.selectionAttrs` reports them as `storyOptions`, null where the stories differ. The Paragraph panel menu has Ignore Optical Margin in place of the per-paragraph Optical Margin Alignment switch, which stays in `type.para`, and Paragraph Style Options › Indents and Spacing has Ignore Optical Margin (#947). No readiness percentage change. +- **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change. - **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change. - **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 85b3a0dc2..08027f379 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -48,6 +48,9 @@ pub struct Spacing { pub hyph_zone: f64, /// Optical margin alignment: punctuation hangs outside the measure (counted as extra width). pub optical: bool, + /// The type size the hang amounts are set for (Story ▸ Optical Margin Alignment's size); + /// `None`: each glyph's own size, so the hang is [`hang`]'s proportion of its advance. + pub optical_size: Option, /// Korean text breaks between syllables instead of at spaces. pub korean_char_breaks: bool, /// 0: minimum adjustment; 1: push in; 2: push out first; 3: push out only. @@ -72,6 +75,7 @@ impl Default for Spacing { ragged_stretch: 24.0, hyph_zone: 0.0, optical: false, + optical_size: None, korean_char_breaks: false, kinsoku_priority: 0, } @@ -318,16 +322,32 @@ pub fn hang(c: char) -> (f64, f64) { } } -fn hang_left(g: &Glyph) -> f64 { - hang(g.ch).0 * g.adv +/// How much more than [`hang`]'s proportions `g` hangs: the hang is set for +/// [`Spacing::optical_size`] and grows with it, relative to the glyph's own size. +pub(crate) fn optical_scale(g: &Glyph, sp: &Spacing) -> f64 { + match sp.optical_size { + Some(s) if s.is_finite() && g.size.is_finite() && g.size > 0.0 => s / g.size, + _ => 1.0, + } +} + +/// Optical hang of `g` at the start of a line (0 without optical margin alignment). +pub(crate) fn hang_left(g: &Glyph, sp: &Spacing) -> f64 { + if sp.optical { hang(g.ch).0 * g.adv * optical_scale(g, sp) } else { 0.0 } +} + +/// Optical hang of `g` at the end of a line (0 without optical margin alignment). +pub(crate) fn hang_right(g: &Glyph, sp: &Spacing) -> f64 { + if sp.optical { hang(g.ch).1 * g.adv * optical_scale(g, sp) } else { 0.0 } } fn right_hang(g: &Glyph, sp: &Spacing) -> f64 { - g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 }) - g.moji.end + g.cjk_hang.max(hang_right(g, sp)) - g.moji.end } -fn hang_right(g: &Glyph) -> f64 { - hang(g.ch).1 * g.adv +/// The part of a hyphen after `g` (of width `hw`) that hangs past the line's end. +fn hyphen_hang(g: &Glyph, hw: f64, sp: &Spacing) -> f64 { + if sp.optical { hw * hang('-').1 * optical_scale(g, sp) } else { 0.0 } } /// Advance of the hyphen glyph that would follow `g` (cached per face). @@ -428,15 +448,13 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S let mut p = Item::penalty(hw, sp.hyphen_penalty, true); p.auto = true; p.zone_from = last_space; - if sp.optical { - p.hang = hw * hang('-').1; - } + p.hang = hyphen_hang(g, hw, sp); push_inword(&mut it, &mut ig, p, i + 1, sp); continue; } let (st, sh) = sp.box_elastic(g); let mut bx = Item::boxed(g.adv, st, sh); - bx.hang = (if sp.optical { hang_left(g) } else { 0.0 }) - g.moji.start; + bx.hang = hang_left(g, sp) - g.moji.start; bx.start_elastic = crate::mojikumi::start_elastic(g, sp.justify, sp.kinsoku_priority != 3); it.push(bx); ig.push(i); @@ -457,9 +475,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S let mut p = Item::penalty(hw, sp.hyphen_penalty, true); p.auto = true; p.zone_from = last_space; - if sp.optical { - p.hang = hw * hang('-').1; - } + p.hang = hyphen_hang(g, hw, sp); push_inword(&mut it, &mut ig, p, i + 1, sp); } else if emergency_after.get(i) == Some(&true) { let mut p = Item::penalty(0.0, EMERGENCY_PENALTY, false); @@ -876,7 +892,7 @@ pub fn greedy( let may_hyphenate = |hyphens: u32| sp.hyphen_limit == 0 || hyphens < sp.hyphen_limit; while start < n { let w = width(line).max(1.0); - let mut x = (if sp.optical { -hang_left(&glyphs[start]) } else { 0.0 }) + glyphs[start].moji.start; + let mut x = -hang_left(&glyphs[start], sp) + glyphs[start].moji.start; let mut shrink = crate::mojikumi::start_elastic(&glyphs[start], sp.justify, sp.kinsoku_priority != 3)[1]; let mut last_natural: Option<(usize, bool)> = None; let mut last_ok: Option<(usize, bool)> = None; // (break position = end glyph, hyphen) @@ -939,7 +955,8 @@ pub fn greedy( last_natural = last_ok; } } else if hyph_after[i] && may_hyphenate(hyphens) { - let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 }; + let hw = hyphen_width(g, &mut hy_cache); + let hy = (hw - hyphen_hang(g, hw, sp)).max(0.0); if x + hy <= w + shrink + FIT_EPS { last_ok = Some((i + 1, true)); } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 687f4c7ea..22a545003 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1136,7 +1136,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let cols_here = &cols[cur.fi]; let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns); let col = SplitCfg::of(&pp).map_or(col, |c| c.sub_rect(col, 0)); - let spacing = spacing_for(&pp, base_chars.size); + let spacing = spacing_for(&pp, base_chars.size, story); let mut lines: Vec<(std::ops::Range, String)> = Vec::new(); for _ in 0..3 { // The same drop cap, spacing, hyphenation and breaker as the layout below. @@ -1292,7 +1292,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); suppress_hyphens(&mut hyph_after, &glyphs, no_hyph.get(pi).map_or(&[], Vec::as_slice)); let base_size = base_chars.size; - let spacing = spacing_for(&pp, base_size); + let spacing = spacing_for(&pp, base_size, story); // An empty paragraph takes its height from its paragraph mark's character format. let mark_chars = if glyphs.is_empty() { std::borrow::Cow::Owned(doc.styles.resolve_char(&base_chars, story.char_format_of(prange.start))) @@ -2303,8 +2303,22 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio out } +/// Optical margin alignment for a paragraph of `story`: (on, the size the hang is set for). +/// Ignore Optical Margin turns it off; the story's setting, when on, applies to every other +/// paragraph; otherwise the paragraph's own switch hangs at each glyph's own size. +fn optical_for(pp: &ParaProps, story: &Story) -> (bool, Option) { + if pp.ignore_optical_margin { + return (false, None); + } + match story.optical_margin.hang_size() { + Some(size) => (true, Some(size)), + None => (pp.optical_margin, None), + } +} + /// Breaker parameters from the paragraph's settings. -fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { +fn spacing_for(pp: &ParaProps, base_size: f64, story: &Story) -> Spacing { + let (optical, optical_size) = optical_for(pp, story); Spacing { justify: pp.align.is_justified(), word_min: pp.word_space_min, @@ -2322,7 +2336,8 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { // The zone is the Single-line Composer's (unjustified text only), as in InDesign: the // Paragraph Composer weighs every break of the paragraph instead. hyph_zone: if pp.align.is_justified() || !pp.composer.single_line() { 0.0 } else { pp.hyph_zone }, - optical: pp.optical_margin, + optical, + optical_size, korean_char_breaks: pp.korean_char_breaks, kinsoku_priority: match pp.kinsoku_type.as_str() { "KinsokuPushInFirst" => 1, @@ -3448,8 +3463,8 @@ fn layout_line( if (sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None) && !has_tab { let first = line.iter().find(|g| g.adv > 0.0 && !g.is_space()); let lastg = line.iter().rev().find(|g| g.adv > 0.0 && !g.is_space()); - let hl = if sp.optical { first.map_or(0.0, |g| breaker::hang(g.ch).0 * g.adv) } else { 0.0 }; - let optical_right = if sp.optical { lastg.map_or(0.0, |g| breaker::hang(g.ch).1 * g.adv) } else { 0.0 }; + let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp)); + let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp)); let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang); let cjk_right = if pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular { cjk_right.min((line.iter().map(|g| g.adv).sum::() - measure).max(0.0)) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index cd4783520..90b4ac3c4 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1358,6 +1358,56 @@ fn optical_margin_hangs_punctuation() { assert!(lines.iter().all(|l| l.end_x <= l.x1 + 12.0)); } +/// How far the opening quote of a one-line paragraph at `text_size` hangs into the left margin, +/// with the story's Optical Margin Alignment `story` (on, size) and the paragraph's attributes. +fn quote_hang(text_size: f64, story: Option, para: ParaAttrs) -> f64 { + let text = "“Quoted” words."; + let first_x = |story: Option, para: ParaAttrs| { + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 400.0, 200.0), para); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..text.len(), |f| f.over.size = Some(text_size)); + if let Some(size) = story { + st.optical_margin = designcraft_doc::OpticalMargin { on: true, size }; + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 1); + lines[0].glyphs[0].x + }; + first_x(None, ParaAttrs::default()) - first_x(story, para) +} + +#[test] +fn story_optical_margin_hang_scales_with_its_size() { + let at12 = quote_hang(12.0, Some(12.0), ParaAttrs::default()); + let at24 = quote_hang(12.0, Some(24.0), ParaAttrs::default()); + assert!(at12 > 1.0, "{at12}"); + assert!((at24 - 2.0 * at12).abs() < 0.01, "{at12} {at24}"); + // The size is the type size the hang is set for: 24 pt text at 24 hangs like 12 at 12, twice as far. + let big = quote_hang(24.0, Some(24.0), ParaAttrs::default()); + assert!((big - 2.0 * at12).abs() < 0.01, "{at12} {big}"); +} + +#[test] +fn ignore_optical_margin_paragraph_does_not_hang() { + let ignored = ParaAttrs { ignore_optical_margin: Some(true), optical_margin: Some(true), ..Default::default() }; + assert_eq!(quote_hang(12.0, Some(12.0), ignored.clone()), 0.0); + assert_eq!(quote_hang(12.0, None, ignored), 0.0); +} + +#[test] +fn paragraph_optical_margin_hangs_at_the_text_size() { + // Documents saved before the story setting existed: the paragraph's switch hangs each + // character by its own size, not the story's default 12 pt. + let own = ParaAttrs { optical_margin: Some(true), ..Default::default() }; + let at24 = quote_hang(24.0, None, own.clone()); + assert!((at24 - quote_hang(24.0, Some(24.0), ParaAttrs::default())).abs() < 0.01); + assert!((at24 - 2.0 * quote_hang(12.0, None, own)).abs() < 0.01); + // The story setting, when on, sets the size. + let story = quote_hang(24.0, Some(12.0), ParaAttrs { optical_margin: Some(true), ..Default::default() }); + assert!((story - at24 / 2.0).abs() < 0.01, "{story} {at24}"); +} + // ---------- golden paragraph corpus ---------- /// Original prose (written for this corpus) with a realistic mix of short and long words. diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index fa7cffc68..587a00280 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -1274,7 +1274,13 @@ attr_set! { /// again with the list (an indent the paragraph had before stays). list_hang: bool = false, balance_ragged: bool = false, + /// Hang punctuation outside the measure in this paragraph, at each character's own size + /// (DesignCraft's own per-paragraph switch; a story with Optical Margin Alignment on + /// overrides it with the story's size). optical_margin: bool = false, + /// Ignore Optical Margin: no hang in this paragraph, whatever the story or + /// `optical_margin` say. + ignore_optical_margin: bool = false, /// None uses the legacy built-in rules; an empty set explicitly disables kinsoku. kinsoku: Option = None => "deserialize_explicit_value", /// Preserved spacing-table reference; compatibility audit reports unsupported composition. diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 43b65144a..53308e1b8 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -318,15 +318,16 @@ impl Document { let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0); let after = other.frames.split_off(pos_to); let before = std::mem::take(&mut other.frames); - let (vertical, direction) = (other.vertical, other.direction); + let (vertical, direction, optical_margin) = (other.vertical, other.direction, other.optical_margin); let offset; { let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) }; - // The story keeps its own direction, even when it was empty and takes the other's text. - let own = (st.vertical, st.direction); + // The story keeps its own direction and options, even when it was empty and takes the + // other's text. + let own = (st.vertical, st.direction, st.optical_margin); st.append_story(other); - (st.vertical, st.direction) = own; + (st.vertical, st.direction, st.optical_margin) = own; let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); for (k, f) in after.iter().enumerate() { st.frames.insert(at + k, *f); @@ -345,6 +346,7 @@ impl Document { ns.frames = before.clone(); ns.vertical = vertical; ns.direction = direction; + ns.optical_margin = optical_margin; for f in &before { if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { t.story = nid; @@ -526,12 +528,12 @@ impl Document { /// stays with the first part and may become overset). pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> { let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; - let (tail, vertical, direction) = { + let (tail, vertical, direction, optical_margin) = { let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?; let tail = st.frames.split_off(p + 1); st.rev = st.rev.wrapping_add(1); - (tail, st.vertical, st.direction) + (tail, st.vertical, st.direction, st.optical_margin) }; if tail.is_empty() { return Ok(()); @@ -541,6 +543,7 @@ impl Document { ns.frames = tail.clone(); ns.vertical = vertical; ns.direction = direction; + ns.optical_margin = optical_margin; self.stories.insert(nid, Arc::new(ns)); for f in tail { if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) { @@ -554,20 +557,21 @@ impl Document { /// `frame` stays on the page with a new empty story. pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> { let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; - let (vertical, direction) = { + let (vertical, direction, optical_margin) = { let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; if st.frames.len() < 2 { return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame"))); } st.frames.retain(|f| *f != frame); st.rev = st.rev.wrapping_add(1); - (st.vertical, st.direction) + (st.vertical, st.direction, st.optical_margin) }; let nid = StoryId(self.alloc()); let mut ns = Story::new(nid); ns.frames = vec![frame]; ns.vertical = vertical; ns.direction = direction; + ns.optical_margin = optical_margin; self.stories.insert(nid, Arc::new(ns)); let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?; t.story = nid; diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index e3a0e39ee..05adcb35f 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -125,6 +125,9 @@ pub struct Story { /// right to left, in every frame of the thread. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub vertical: bool, + /// Story ▸ Optical Margin Alignment. + #[serde(default, skip_serializing_if = "OpticalMargin::is_default")] + pub optical_margin: OpticalMargin, /// Bumped on every edit (composition cache key). #[serde(default)] pub rev: u64, @@ -160,6 +163,45 @@ pub struct Story { pub file_link: Option, } +/// Story ▸ Optical Margin Alignment: punctuation and the edges of some letters hang outside the +/// frame's edges, in every paragraph of the story except those with Ignore Optical Margin. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct OpticalMargin { + pub on: bool, + /// The type size (points) the hang amounts are set for: text of this size hangs by the + /// built-in proportions of each character's advance, and the hang grows with this size. + pub size: f64, +} + +impl Default for OpticalMargin { + fn default() -> Self { + OpticalMargin { on: false, size: Self::DEFAULT_SIZE } + } +} + +impl OpticalMargin { + pub const DEFAULT_SIZE: f64 = 12.0; + /// InDesign's range for the size. + pub const MIN_SIZE: f64 = 0.1; + pub const MAX_SIZE: f64 = 1296.0; + + /// `v` clamped to the size range, or `None` when it isn't a finite number. + pub fn clamp_size(v: f64) -> Option { + v.is_finite().then(|| v.clamp(Self::MIN_SIZE, Self::MAX_SIZE)) + } + + /// The size the story's text hangs for, when the setting is on (a stored size outside the + /// range is clamped, a non-finite one reads as the default). + pub fn hang_size(&self) -> Option { + self.on.then(|| Self::clamp_size(self.size).unwrap_or(Self::DEFAULT_SIZE)) + } + + pub fn is_default(&self) -> bool { + *self == Self::default() + } +} + /// A linked text file: where a placed story came from, what the file held, and the Word Import /// Options that brought it in, so Update Link can import it again the same way. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -201,6 +243,7 @@ impl Story { frames: vec![], direction: crate::TextDirection::LeftToRight, vertical: false, + optical_margin: OpticalMargin::default(), rev: 0, tables: BTreeMap::new(), notes: Vec::new(), @@ -596,6 +639,22 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + #[test] + fn optical_margin_is_optional_in_saved_stories() { + let plain = serde_json::to_value(st("Text")).unwrap(); + assert!(plain.get("opticalMargin").is_none()); + let old: Story = serde_json::from_value(plain).unwrap(); + assert_eq!(old.optical_margin, OpticalMargin { on: false, size: 12.0 }); + let mut s = st("Text"); + s.optical_margin = OpticalMargin { on: true, size: 18.0 }; + let back: Story = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap(); + assert_eq!(back.optical_margin, s.optical_margin); + // A hostile stored size is clamped where it is used. + assert_eq!(OpticalMargin { on: true, size: 1e9 }.hang_size(), Some(OpticalMargin::MAX_SIZE)); + assert_eq!(OpticalMargin { on: true, size: -3.0 }.hang_size(), Some(OpticalMargin::MIN_SIZE)); + assert_eq!(OpticalMargin { on: false, size: 18.0 }.hang_size(), None); + } + #[test] fn paragraphs_track_newlines() { let mut s = st("one\ntwo"); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..ab24a05d8 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -302,7 +302,7 @@ pub fn specs() -> Vec { "New Paragraph Style…", [], None, - "{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}", + "{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, ignoreOpticalMargin, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}", has_doc, create_para ), @@ -311,7 +311,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", + "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", has_doc, edit_para ), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3a40844be..43038f76a 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -104,6 +104,15 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "story.options", + "Story Options", + [], + None, + "{story?, frame?: a text frame of the story (default: the stories of the text selection or the selected frames), opticalMargin?: bool — Optical Margin Alignment: punctuation and letter edges hang outside the frame in every paragraph of the story except those with ignoreOpticalMargin, opticalMarginSize?: pt (0.1–1296, default 12) — the type size the hang amounts are set for} → {stories}", + has_doc, + story_options + ), cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?); let text = super::text_param(p, "text"); @@ -182,7 +191,7 @@ pub fn specs() -> Vec { "Paragraph Formatting", [], None, - "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", + "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, opticalMargin?: bool (hang at each character's own size; a story with story.options opticalMargin overrides it), ignoreOpticalMargin?: bool (no hang in the paragraph), tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", has_text_or_frames, |s, p| format_paras(s, p.get("attrs").unwrap_or(p)) ), @@ -374,7 +383,7 @@ pub fn specs() -> Vec { "Selection Attributes", [], None, - "{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)", + "{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)", has_doc, selection_attrs ), @@ -991,11 +1000,11 @@ fn sentence_bounds(s: &str, i: usize) -> (usize, usize) { (pa + start, pb) } -/// Type ▸ Story Direction. -fn story_direction(s: &mut Session, p: &Value) -> Result { - let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; +/// The stories of the text selection, the selected cells and the selected text frames. +fn selected_stories(s: &Session) -> Result> { let st = s.doc()?; let mut stories: Vec = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect(); + stories.dedup(); for id in &st.selection.items { if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) && !stories.contains(&tf.story) @@ -1003,6 +1012,54 @@ fn story_direction(s: &mut Session, p: &Value) -> Result { stories.push(tf.story); } } + Ok(stories) +} + +/// Type ▸ Story: the story-wide options (Optical Margin Alignment and its size). +fn story_options(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "story.options"; + let on = match p.get("opticalMargin") { + None => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(CMD, "`opticalMargin` must be a bool"))?), + }; + let size = match p.get("opticalMarginSize") { + None => None, + Some(v) => Some( + v.as_f64() + .and_then(designcraft_doc::OpticalMargin::clamp_size) + .ok_or_else(|| bad(CMD, "`opticalMarginSize` must be a finite number of points"))?, + ), + }; + if on.is_none() && size.is_none() { + return Err(bad(CMD, "nothing to set: give opticalMargin or opticalMarginSize")); + } + let stories = if p.get("story").is_some() || p.get("frame").is_some() { + vec![story_of(s, p).ok_or_else(|| bad(CMD, "no such story"))?] + } else { + selected_stories(s)? + }; + if stories.is_empty() { + return Err(bad(CMD, "no story: select text or a text frame, or give story or frame")); + } + s.edit(|d, _| { + for sid in &stories { + let st = d.story_mut(*sid).ok_or(designcraft_doc::DocError::NoStory(*sid))?; + let mut om = st.optical_margin; + om.on = on.unwrap_or(om.on); + om.size = size.unwrap_or(om.size); + if om != st.optical_margin { + st.optical_margin = om; + st.rev = st.rev.wrapping_add(1); + } + } + Ok(json!({"stories": stories.len()})) + }) +} + +/// Type ▸ Story Direction. +fn story_direction(s: &mut Session, p: &Value) -> Result { + let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; + let stories = selected_stories(s)?; s.edit(|d, _| { set_story_direction(d, &stories, vertical); Ok(json!({"stories": stories.len()})) @@ -1836,12 +1893,30 @@ fn selection_attrs(s: &mut Session, p: &Value) -> Result { "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, "selectedText": selected.trim()}); + let mut stories = selected_stories(s)?; + if stories.is_empty() { + stories.push(sid); + } + out["storyOptions"] = story_options_of(&st.doc, &stories); if bool_or(p, "mixed", false) { out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets)); } Ok(out) } +/// The story options of `stories`, as story.options names them; a value that differs between +/// the stories is null. +fn story_options_of(doc: &designcraft_doc::Document, stories: &[StoryId]) -> Value { + let mut values = stories.iter().filter_map(|sid| doc.story(*sid)).map(|st| st.optical_margin); + let Some(first) = values.next() else { return json!({"opticalMargin": null, "opticalMarginSize": null}) }; + let (mut on, mut size) = (Some(first.on), Some(first.size)); + for om in values { + on = on.filter(|v| *v == om.on); + size = size.filter(|v| *v == om.size); + } + json!({"opticalMargin": on, "opticalMarginSize": size}) +} + /// The paragraph attributes whose resolved values differ across the paragraphs of `targets`. fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec { let mut first: Option> = None; @@ -3280,6 +3355,90 @@ mod bidi_caret_tests { } } +#[cfg(test)] +mod story_options_tests { + use super::*; + + fn first_x(s: &Session, sid: StoryId) -> f64 { + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + cs.frames[0].lines[0].glyphs[0].x + } + + fn story(s: &Session, sid: StoryId) -> designcraft_doc::OpticalMargin { + s.doc().unwrap().doc.story(sid).unwrap().optical_margin + } + + #[test] + fn story_options_set_optical_margin_with_one_undo_step_each() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let flush = first_x(&s, sid); + // The selected frame's story. + s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); + assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 }); + let hung = first_x(&s, sid); + assert!(hung < flush - 1.0, "{hung} {flush}"); + s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 24})).unwrap(); + assert_eq!(story(&s, sid).size, 24.0); + assert!(first_x(&s, sid) < hung - 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 }); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!story(&s, sid).on); + assert_eq!(first_x(&s, sid), flush); + // Hostile sizes. + s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 1e9})).unwrap(); + assert_eq!(story(&s, sid).size, designcraft_doc::OpticalMargin::MAX_SIZE); + for bad in [json!({"opticalMarginSize": "big"}), json!({"opticalMargin": 1}), json!({}), json!({"story": 999_999, "opticalMargin": true})] { + assert!(s.execute("story.options", &bad).is_err(), "{bad}"); + } + } + + #[test] + fn ignore_optical_margin_through_type_para_and_paragraph_styles() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let flush = first_x(&s, sid); + s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); + assert!(first_x(&s, sid) < flush - 1.0); + let ignored = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.ignore_optical_margin; + s.execute("type.para", &json!({"attrs": {"ignoreOpticalMargin": true}})).unwrap(); + assert_eq!(ignored(&s), Some(true)); + assert_eq!(first_x(&s, sid), flush); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ignored(&s), None); + s.execute("style.paragraph.create", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": true}})).unwrap(); + let resolved = |s: &Session| s.doc().unwrap().doc.styles.resolve_para_style("Flush").0.ignore_optical_margin; + assert!(resolved(&s)); + s.execute("style.paragraph.edit", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": false}})).unwrap(); + assert!(!resolved(&s)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(resolved(&s)); + s.execute("style.paragraph.apply", &json!({"name": "Flush"})).unwrap(); + assert_eq!(first_x(&s, sid), flush); + } + + #[test] + fn selection_attrs_report_the_story_options_with_differences_as_null() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "One"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Two"})).unwrap(); + let options = |s: &mut Session| s.execute("type.selectionAttrs", &json!({})).unwrap()["storyOptions"].clone(); + assert_eq!(options(&mut s), json!({"opticalMargin": false, "opticalMarginSize": 12.0})); + s.execute("story.options", &json!({"story": b["story"], "opticalMargin": true, "opticalMarginSize": 18})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": true, "opticalMarginSize": 18.0}), "the selected frame's story"); + s.execute("selection.set", &json!({"ids": [a["id"], b["id"]]})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": null})); + s.execute("story.options", &json!({"opticalMarginSize": 18})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": 18.0})); + } +} + #[cfg(test)] mod arabic_tests { use super::*; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0106364e2..3a9dc778d 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1528,6 +1528,7 @@ impl<'a> Ex<'a> { } n!(keep_with_next, "KeepWithNext"); b!(keep_lines_together, "KeepLinesTogether"); + b!(ignore_optical_margin, "IgnoreEdgeAlignment"); b!(keep_all_lines, "KeepAllLinesTogether"); n!(keep_first, "KeepFirstLines"); n!(keep_last, "KeepLastLines"); @@ -2226,8 +2227,11 @@ impl<'a> Ex<'a> { .attr("StoryTitle", "$ID/"); el.push( El::new("StoryPreference") - .attr("OpticalMarginAlignment", "false") - .attr("OpticalMarginSize", "12") + .attr("OpticalMarginAlignment", bool_s(s.optical_margin.on)) + .attr( + "OpticalMarginSize", + num(designcraft_doc::OpticalMargin::clamp_size(s.optical_margin.size).unwrap_or(designcraft_doc::OpticalMargin::DEFAULT_SIZE)), + ) .attr("FrameType", "TextFrameType") .attr("StoryOrientation", if s.vertical { "Vertical" } else { "Horizontal" }) .attr( diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 77d730fa0..1000638d3 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1820,6 +1820,7 @@ impl<'r> Importer<'r> { a.single_word_justify = e.prop("SingleWordJustification").and_then(|v| names::align_in(v.trim())); a.keep_with_next = u("KeepWithNext"); a.keep_lines_together = e.boolean("KeepLinesTogether"); + a.ignore_optical_margin = e.boolean("IgnoreEdgeAlignment"); a.keep_all_lines = e.boolean("KeepAllLinesTogether"); a.keep_first = u("KeepFirstLines"); a.keep_last = u("KeepLastLines"); @@ -2095,6 +2096,14 @@ impl<'r> Importer<'r> { let format = self.xref_format_names.get(&fmt).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into()); xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format })); } + let pref = e.find("StoryPreference"); + let optical_margin = designcraft_doc::OpticalMargin { + on: pref.and_then(|p| p.boolean("OpticalMarginAlignment")).unwrap_or(false), + size: pref + .and_then(|p| p.num("OpticalMarginSize")) + .and_then(designcraft_doc::OpticalMargin::clamp_size) + .unwrap_or(designcraft_doc::OpticalMargin::DEFAULT_SIZE), + }; let mut st = Story { id, text, @@ -2107,6 +2116,7 @@ impl<'r> Importer<'r> { designcraft_doc::TextDirection::LeftToRight }, vertical: e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical"), + optical_margin, rev: 0, tables, notes, diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 970567e7c..fcee91706 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -300,6 +300,7 @@ fn indesign_text_defaults() -> Vec { "HyphenateLadderLimit": "3" => 3, "0" => 0, |r| r.0.hyph_limit; "HyphenationZone": "36" => 36.0, "18" => 18.0, |r| r.0.hyph_zone; "HyphenateCapitalizedWords": "true" => true, "false" => false, |r| r.0.hyph_capitalized; + "IgnoreEdgeAlignment": "false" => false, "true" => true, |r| r.0.ignore_optical_margin; "HyphenateLastWord": "true" => true, "false" => false, |r| r.0.hyph_last_word; "HyphenateAcrossColumns": "true" => true, "false" => false, |r| r.0.hyph_across_column; "HyphenWeight": "5" => 0.5, "9" => 0.9, |r| r.0.hyph_weight; @@ -484,6 +485,57 @@ fn text_color_rules_round_trip() { assert_eq!(p.para.kashidas, Some(false)); } +fn optical_story(pref: &str) -> String { + format!( + r#" + + + + + “Hung” here.
    +
    + + “Flush” here. + +
    +
    "# + ) +} + +/// Story ▸ Optical Margin Alignment and its size, and Ignore Optical Margin on paragraphs and +/// paragraph styles, survive import and export. +#[test] +fn story_optical_margin_round_trips() { + let story = |d: &Document| d.stories.values().find(|s| s.text.starts_with("“Hung”")).cloned().unwrap(); + let d = import_idml_with(&fixture_with_story(&optical_story(r#"OpticalMarginAlignment="true" OpticalMarginSize="18""#)), &|_| None).unwrap(); + let s = story(&d); + assert_eq!(s.optical_margin, designcraft_doc::OpticalMargin { on: true, size: 18.0 }); + assert_eq!((s.paras[0].para.ignore_optical_margin, s.paras[1].para.ignore_optical_margin), (None, Some(true))); + let bytes = export_idml(&d); + let text = zip_text(&bytes, "Stories/"); + assert!(text.contains(r#"OpticalMarginAlignment="true" OpticalMarginSize="18""#), "{text}"); + assert!(text.contains(r#"IgnoreEdgeAlignment="true""#), "{text}"); + let back = story(&import_idml(&bytes).unwrap()); + assert_eq!(back.optical_margin, s.optical_margin); + assert_eq!(back.paras[1].para.ignore_optical_margin, Some(true)); + // Sizes outside InDesign's range are clamped; malformed ones and an absent preference are the default. + for (pref, want) in [ + (r#"OpticalMarginAlignment="true" OpticalMarginSize="1e9""#, (true, 1296.0)), + (r#"OpticalMarginAlignment="true" OpticalMarginSize="0""#, (true, 0.1)), + (r#"OpticalMarginAlignment="true" OpticalMarginSize="NaN""#, (true, 12.0)), + (r#"OpticalMarginAlignment="false""#, (false, 12.0)), + ] { + let d = import_idml_with(&fixture_with_story(&optical_story(pref)), &|_| None).unwrap(); + let om = story(&d).optical_margin; + assert_eq!((om.on, om.size), want, "{pref}"); + } + // On a paragraph style. + let d = import_idml_with(&fixture_with_styles(&keep_styles(r#"IgnoreEdgeAlignment="true""#)), &|_| None).unwrap(); + assert!(d.styles.resolve_para_style("Body").0.ignore_optical_margin); + let styles = zip_text(&export_idml(&d), "Resources/Styles.xml"); + assert!(styles.contains(r#"IgnoreEdgeAlignment="true""#), "{styles}"); +} + /// `[Basic Paragraph]` is InDesign's `$ID/NormalParagraphStyle`, both ways. #[test] fn basic_paragraph_is_normal_paragraph_style() { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 303c08703..5183222eb 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -579,7 +579,7 @@ const TRACKING: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 1 const PARA_MODE: &str = "ctl_text_para_mode"; /// An icon and a field after it, as the Control panel lays its fields out. -fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Option { +pub(crate) fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Option { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); icons::paint(ui.painter(), r, icon, t.icon); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5245e00be..04d2391f3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4059,6 +4059,11 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); ui.end_row(); }); + ui.add_space(6.0); + let mut ignore = cur(d, "p.ignoreOpticalMargin", &pv["ignoreOpticalMargin"]).as_bool().unwrap_or(false); + if ui.checkbox(&mut ignore, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ignore Optical Margin"))).changed() { + d.fields.insert("p.ignoreOpticalMargin".into(), json!(ignore)); + } } "tabs" => style_tabs(&app.ui.language, ui, d, &pv, &doc), "rules" => paragraph_rules(app, ui, d, "p.", &pv), @@ -7867,6 +7872,35 @@ mod tests { assert_eq!(props.number_label(3), "III)\t"); } + #[test] + fn paragraph_style_options_ignore_optical_margin_is_set_on_ok() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Flush"})).unwrap(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Flush", "section": "indents"}))); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("ignore_optical_margin_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Ignore Optical Margin").click(); + h.run_steps(3); + h.get_by_label(" OK ").click(); + h.run_steps(3); + let app = h.state(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.styles.para("Flush").unwrap().para.ignore_optical_margin, Some(true)); + } + #[test] fn paragraph_style_options_keep_a_bad_grep_pattern_open_with_the_error() { let (mut app, _) = ruled_paragraph(); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d4551e813..65e4420ad 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -23,6 +23,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("typography", "Typography", "panel-typography"), ("glyphs", "Glyphs", "panel-character"), ("paragraph", "Paragraph", "panel-paragraph"), + ("story", "Story", "panel-text-frame"), ("textWrap", "Text Wrap", "panel-wrap"), ("table", "Table", "panel-table"), ("align", "Align", "panel-align"), @@ -285,6 +286,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "typography" => panels::typography::show(app, ui), "glyphs" => panels::glyphs::show(app, ui), "paragraph" => panels::properties::paragraph_panel(app, ui), + "story" => panels::story::show(app, ui), "textWrap" => panels::properties::wrap_panel(app, ui), "align" => panels::properties::align_panel(app, ui), "pathfinder" => panels::properties::pathfinder_panel(app, ui), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 75053ef1b..b7382c906 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -913,6 +913,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Conditional Text|{\"panel\": \"conditions\"}", "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", "ui:window.panel|Paragraph|{\"panel\": \"paragraph\"}", + "ui:window.panel|Story|{\"panel\": \"story\"}", "ui:window.panel|Table|{\"panel\": \"table\"}", "ui:app.tabsPanel", "ui:window.panel|Typography|{\"panel\": \"typography\"}", @@ -966,7 +967,8 @@ pub const CLASSIC_GROUPS: &[&[&str]] = &[ ]; /// The Typography preset's available panels in the order observed in InDesign's workspace. -/// CC Libraries and the separate Story panel have no corresponding DesignCraft panel. +/// CC Libraries has no corresponding DesignCraft panel, and where the Story panel sits in it is +/// not recorded, so these groups leave it out. pub const TYPOGRAPHY_GROUPS: &[&[&str]] = &[ &["pages", "layers", "swatches"], &["glyphs", "textWrap"], diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 85058d7a8..0624d0c66 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -12,6 +12,7 @@ pub mod library; pub mod notes; pub mod pages; pub mod properties; +pub mod story; pub mod styles; pub mod swatches; pub mod table; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 6deace3fc..fda46efad 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2089,9 +2089,10 @@ fn paragraph_panel_menu(app: &mut DesignApp, ui: &mut Ui, p: &Value) { if item(ui, "Balance Ragged Lines", Some(balance), true) { attrs = Some(json!({"balanceRagged": !balance})); } - let optical = p["opticalMargin"].as_bool().unwrap_or(false); - if item(ui, "Optical Margin Alignment", Some(optical), true) { - attrs = Some(json!({"opticalMargin": !optical})); + // Story-wide Optical Margin Alignment is in the Story panel. + let ignore = p["ignoreOpticalMargin"].as_bool().unwrap_or(false); + if item(ui, "Ignore Optical Margin", Some(ignore), true) { + attrs = Some(json!({"ignoreOpticalMargin": !ignore})); } ui.separator(); for (label, id) in [ @@ -3451,6 +3452,22 @@ mod tests { let _ = h.get_by_label("✓ Only Align First Line to Grid"); } + #[test] + fn paragraph_panel_menu_toggles_ignore_optical_margin() { + let mut h = panel_harness(text_app(), paragraph_panel); + h.run_steps(3); + h.get_by_label("☰").click(); + h.run_steps(3); + h.get_by_label(" Ignore Optical Margin").click(); + h.run_steps(3); + assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["ignoreOpticalMargin"], true); + h.get_by_label("☰").click(); + h.run_steps(3); + h.get_by_label("✓ Ignore Optical Margin").click(); + h.run_steps(3); + assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["ignoreOpticalMargin"], false); + } + #[test] fn paragraph_panel_menu_define_lists_opens_its_dialog() { let mut h = panel_harness(text_app(), paragraph_panel); diff --git a/crates/ui-egui/src/panels/story.rs b/crates/ui-egui/src/panels/story.rs new file mode 100644 index 000000000..adb2ed1d5 --- /dev/null +++ b/crates/ui-egui/src/panels/story.rs @@ -0,0 +1,106 @@ +//! Window › Type & Tables › Story: the story-wide options. + +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::widgets::{self, NumField}; + +/// The Story panel: Optical Margin Alignment for the stories of the text selection or the +/// selected text frames, and the type size its hang amounts are set for. Without text or a text +/// frame selected, the controls are disabled. +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + let options = super::text_attrs(app).map(|a| a["storyOptions"].clone()); + let enabled = options.is_some(); + let options = options.unwrap_or(Value::Null); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal_wrapped(|ui| { + // Stories that differ show unchecked; a click turns it on for all of them. + let mut on = options["opticalMargin"].as_bool().unwrap_or(false); + if widgets::checkbox(ui, &mut on, &tr("Optical Margin Alignment")).changed() { + let _ = app.run("story.options", json!({"opticalMargin": on})); + } + let field = NumField::number("storyOpticalMarginSize", options["opticalMarginSize"].as_f64(), " pt", 2) + .width(72.0) + .range(designcraft_doc::OpticalMargin::MIN_SIZE, designcraft_doc::OpticalMargin::MAX_SIZE); + if let Some(size) = crate::chrome::icon_field(ui, "font-size", &tr("Optical Margin Size"), field) { + let _ = app.run("story.options", json!({"opticalMarginSize": size})); + } + }); + }); +} + +#[cfg(test)] +mod tests { + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(egui::vec2(260.0, 200.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + fn optical_margin(h: &Harness<'static, DesignApp>, story: u64) -> designcraft_doc::OpticalMargin { + h.state().session.active().unwrap().doc.story(designcraft_doc::StoryId(story)).unwrap().optical_margin + } + + fn size_field_text(h: &Harness<'static, DesignApp>) -> Option { + h.get_all_by_role(egui::accesskit::Role::TextInput).next().unwrap().value() + } + + #[test] + fn story_panel_sets_and_shows_the_storys_optical_margin() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let story = + app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "“Hung”", "caret": true})).unwrap()["story"] + .as_u64() + .unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + let mut h = harness(app); + assert_eq!(size_field_text(&h).as_deref(), Some("12 pt")); + h.get_by_label("Optical Margin Alignment").click(); + h.run_steps(3); + assert!(optical_margin(&h, story).on); + assert_eq!(h.get_by_label("Optical Margin Alignment").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + h.get_all_by_role(egui::accesskit::Role::TextInput).next().unwrap().click(); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("18".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(optical_margin(&h, story), designcraft_doc::OpticalMargin { on: true, size: 18.0 }); + // A value set elsewhere shows in the panel. + h.state_mut().run("story.options", json!({"opticalMarginSize": 30})).unwrap(); + h.run_steps(3); + assert_eq!(size_field_text(&h).as_deref(), Some("30 pt")); + } + + #[test] + fn story_panel_is_disabled_without_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut h = harness(app); + assert!(h.get_by_label("Optical Margin Alignment").accesskit_node().is_disabled()); + h.get_by_label("Optical Margin Alignment").click(); + h.run_steps(3); + assert_eq!(h.get_by_label("Optical Margin Alignment").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); + } +} diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 9d3f7d4d0..8c92e8dfa 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-16 · **Change:** minor (Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Optical Margin Alignment per story with a size in the Story panel, and Ignore Optical Margin in the Paragraph panel menu and paragraph styles, kept through IDML (#947); Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); 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 @@ -58,7 +58,7 @@ Fidelity: **unmeasured** unless stated. | Tabs | left/center/right/decimal/align-on, leaders, right-indent tab | ✓ | ~ | Tabs panel with a ruler over the text column, and a Tabs section in Paragraph Style Options (#343); wrapping of lines with tabs fixed (#282) | | Paragraph rules, borders, shading | ✓ | ✓ | ✓ | | | Balance ragged lines | ✓ | ✓ | ✓ | | -| Optical margin alignment | ✓ | ✓ (hang table of our own) | ~ | hang amounts are ours, InDesign's unknown | +| Optical margin alignment | per story with a size, Ignore Optical Margin per paragraph and style | ✓ (hang table of our own, scaled by the story's size; Story panel; IDML `StoryPreference`, `IgnoreEdgeAlignment`) | ~ | hang amounts are ours, InDesign's unknown (#947) | | Text wrap | bounding box, shape, jump object/column, wrap sides (text on both sides of an object), contour options, on page items and anchored objects | ✓ | ~ | Detect Edges, Alpha Channel and Photoshop Path contours follow the frame's path (composition doesn't read the image) | | Anchored objects | inline, above line, custom | ✓ | ~ | anchored objects in table cells not drawn (#163) | | Footnotes, endnotes | ✓ | ✓ | ~ | footnotes don't split across columns yet | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2430b489b..4366847f3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -47,7 +47,7 @@ basics of editing fail for too many users on Windows and Linux. | Quick Apply | ✓ | ✓ | ✓ | | | Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | | | 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, Tabs | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) | +| 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, Tabs, Story | ✓ | all present | ~ | 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, 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 | @@ -122,6 +122,7 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45 | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Panels row: Story panel (Optical Margin Alignment and its size) | | 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place | | 2026-10-11 | minor | Live corners row to ✓: widget, diamonds, drag, Shift-drag, Alt-click shape cycling (#186) | | 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests | From a16f29a6697fe0e2cfa82e9b47216b234cf28cbf Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:45:04 -0400 Subject: [PATCH 39/71] Optical margin alignment: lines with a tab hang their edge glyphs, as both line breakers measure them (#1760) layout_line set a line with a tab or a right indent tab without the optical margin or CJK edge hangs, while the greedy breaker and the paragraph composer counted them. A line that fit only because its period hangs was then squeezed into the measure or set past it. layout_line now hangs the edge glyphs of every line. Tabs are measured from the hung start, so tab stops stay where they are, and a right indent tab fills to the hung end. A tab at a line edge hangs nothing. Lines without tabs lay out as before. --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 34 ++++++++++++----------- crates/compose/src/tests.rs | 55 +++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8b1788a0..1bb8c7379 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Optical Margin Alignment and hanging punctuation apply to lines with a tab or a right indent tab, as both composers already measured them: the edge glyphs hang, and tab stops stay where they are. Such a line was set without the hang: a justified line that fit only because its period or closing quote hangs had its word spaces squeezed to make up for it, and text after a right indent tab stopped at the measure instead of hanging. No readiness percentage change. - **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change. - **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change. - **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 6b6a61618..543f2430d 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3419,9 +3419,20 @@ fn layout_line( let h = shape::hyphen_after(g); line.push(h); } - // Tabs: compute widths left to right. let measure = x1 - x0; - let mut x = 0.0; + // Optical margin alignment and hanging punctuation: the glyphs at the line's edges hang + // outside the measure. A tab at an edge hangs nothing. + let is_tab = |g: &Glyph| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB; + let at_edge = |g: &&Glyph| is_tab(g) || (g.adv > 0.0 && !g.is_space()); + let first = line.iter().find(at_edge); + let lastg = line.iter().rev().find(at_edge); + let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp)); + let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp)); + let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang); + let regular_hang = pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular; + // Tabs: compute widths left to right. Tab stops stay where they are when the line hangs, so + // tabs are measured from the hung start, and a right indent tab reaches the hung end. + let mut x = -hl; let mut i = 0; let mut leaders: Vec<(usize, String)> = Vec::new(); // The line's last tab and whether it is a left tab (only text after a left tab justifies). @@ -3451,26 +3462,17 @@ fn layout_line( leaders.push((i, t.leader.clone())); } let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum(); - line[i].adv = (measure - x - rest).max(0.0); + let hr = if regular_hang { optical_right } else { optical_right.max(cjk_right) }; + line[i].adv = (measure + hr - x - rest).max(0.0); last_tab = Some((i, false)); } x += line[i].adv; i += 1; } - let has_tab = line.iter().any(|g| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB); - // Optical margin alignment: the measure grows by the hang of the edge glyphs. + // The measure grows by the hang of the edge glyphs. let (mut x0, mut measure) = (x0, measure); - if (sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None) && !has_tab { - let first = line.iter().find(|g| g.adv > 0.0 && !g.is_space()); - let lastg = line.iter().rev().find(|g| g.adv > 0.0 && !g.is_space()); - let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp)); - let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp)); - let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang); - let cjk_right = if pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular { - cjk_right.min((line.iter().map(|g| g.adv).sum::() - measure).max(0.0)) - } else { - cjk_right - }; + if sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None { + let cjk_right = if regular_hang { cjk_right.min((line.iter().map(|g| g.adv).sum::() - measure).max(0.0)) } else { cjk_right }; let hr = optical_right.max(cjk_right); x0 -= hl; measure += hl + hr; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 90b4ac3c4..54d0852f0 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1358,6 +1358,61 @@ fn optical_margin_hangs_punctuation() { assert!(lines.iter().all(|l| l.end_x <= l.x1 + 12.0)); } +#[test] +fn optical_margin_hangs_the_edges_of_lines_with_tabs() { + // Both line breakers let the edge glyphs of a line hang, tab or not, so layout hangs them + // too: a line that fits only because its period hangs is not squeezed into the measure or + // set past it. The left hang leaves tab stops where they are. + let stop = designcraft_doc::TabStop { position: 40.0, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; + let rest = "right here. More words follow it on the next line."; + let cases = [ + ("tab", format!("“A”\tsits {rest}"), Align::LeftJustified), + ("no tab", format!("“A” sits {rest}"), Align::LeftJustified), + ("right indent tab", format!("“A” sits{}{rest}", story::RIGHT_INDENT_TAB), Align::Left), + ]; + for (name, text, align) in &cases { + let ch = |g: &PlacedGlyph| text.get(g.byte..).and_then(|s| s.chars().next()).unwrap_or(' '); + let edge_glyphs = |l: &Line| -> Vec { l.glyphs.iter().filter(|g| g.visible && !ch(g).is_whitespace()).cloned().collect() }; + for composer in [Composer::Paragraph, Composer::SingleLine] { + let mut ending = 0; + for w in 100..=240 { + let para = ParaAttrs { + align: Some(*align), + optical_margin: Some(true), + hyphenate: Some(false), + composer: Some(composer), + tabs: Some(vec![stop.clone()]), + ..Default::default() + }; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, f64::from(w), 1000.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + for (i, l) in lines.iter().enumerate() { + let Some(last) = edge_glyphs(l).last().cloned() else { continue }; + let hang = breaker::hang(ch(&last)).1 * last.adv; + let end = last.x + last.adv; + assert!(end <= l.x1 + hang + 0.01, "{name}, {composer:?}, width {w}: line {i} ends at {end}, past {} and its hang {hang}", l.x1); + } + let glyphs = edge_glyphs(lines[0]); + let (x0, x1) = (lines[0].x0, lines[0].x1); + let first = &glyphs[0]; + assert!((first.x + first.adv - x0).abs() < 0.01, "{name}, {composer:?}, width {w}: the opening quote is at {}", first.x); + if *name == "tab" { + let s = glyphs.iter().find(|g| ch(g) == 's').unwrap(); + assert!((s.x - (x0 + 40.0)).abs() < 0.01, "{composer:?}, width {w}: the text after the tab starts at {}", s.x); + } + // Line 0 ends with the period of "here.": the period hangs past the measure. + let last = glyphs.last().unwrap(); + if text.get(last.byte..).is_some_and(|s| s.starts_with(". More")) { + ending += 1; + assert!((last.x - x1).abs() < 0.01, "{name}, {composer:?}, width {w}: the period starts at {}, not at {x1}", last.x); + } + } + assert!(ending > 3, "{name}, {composer:?}: line 0 ends with the period at {ending} widths"); + } + } +} + /// How far the opening quote of a one-line paragraph at `text_size` hangs into the left margin, /// with the story's Optical Margin Alignment `story` (on, size) and the paragraph's attributes. fn quote_hang(text_size: f64, story: Option, para: ParaAttrs) -> f64 { From ab8ec71234049815b83492122d8429b22a01da40 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 24 Oct 2026 15:37:10 -0600 Subject: [PATCH 40/71] PDF: keep both layer readers compiling after the merge Graphic gained an optional layer_options field, so the test-only literals in the PDF crate and in the engine's optional-content support need it. No behaviour changes. --- crates/engine/tests/support/optional_content.rs | 1 + crates/pdf/src/export.rs | 1 + crates/pdf/src/registration_tests.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/engine/tests/support/optional_content.rs b/crates/engine/tests/support/optional_content.rs index 3ed73d74f..b3acb3511 100644 --- a/crates/engine/tests/support/optional_content.rs +++ b/crates/engine/tests/support/optional_content.rs @@ -115,6 +115,7 @@ pub fn document(bytes: Vec, hidden_sets: &[&[&str]]) -> Document { crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), + layer_options: None, }); doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dd59ff418..d06fdde0c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -2009,6 +2009,7 @@ mod image_content_cache_tests { crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), + layer_options: None, }; ex.graphic_unclipped(&mut surface, &graphic, hidden.as_deref()); assert!(ex.layer_error.is_none()); diff --git a/crates/pdf/src/registration_tests.rs b/crates/pdf/src/registration_tests.rs index 278100304..d3d76f961 100644 --- a/crates/pdf/src/registration_tests.rs +++ b/crates/pdf/src/registration_tests.rs @@ -17,6 +17,7 @@ fn placed_document(source: Vec) -> Document { crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), + layer_options: None, }); doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); doc From f09df5123e0e8357383c091a0cf4e3837dabd9a4 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 24 Oct 2026 15:37:12 -0600 Subject: [PATCH 41/71] M6: keep placed-PDF layer choices for parent-page and anchored placements The IDML layer-variant pass and the PDF/A-2b rasterisation pass both walked only document-spread items, so a choice on a parent-page placement (or an anchored one) was replaced by the file's defaults on export, and an external-only IDML export succeeded when it could not carry the choice. Both now cover every placement through Document::placed_items, and the choice is cleared where the placement actually is. Regression: a parent-page placement with an explicit all-on choice keeps all layers in embedded IDML, external-only IDML rejects it, and PDF/A-2b rasterises it. --- crates/doc/src/assets.rs | 9 ++++++ crates/doc/src/edit.rs | 18 ++++++++++++ crates/engine/src/cmd/export.rs | 39 +++++++++++++++++-------- crates/engine/src/cmd/interchange.rs | 43 +++++++++++++++++----------- crates/engine/src/cmd/pdflayers.rs | 32 +++++++++++++++++++++ 5 files changed, 112 insertions(+), 29 deletions(-) diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index c526c98ba..1533b8b4b 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -59,6 +59,15 @@ impl Document { || self.stories.values().any(|story| story_any(story, 0, &all_on)) } + /// Whether any placed PDF, on document or parent spreads, in groups or anchored, has an + /// Object Layer Options choice (hidden layers or explicit all-on). Exceeding the traversal + /// budget conservatively says yes. + pub fn has_pdf_layer_overrides(&self) -> bool { + let chosen = |it: &Item| it.has_pdf_layer_override(); + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &chosen)) + || self.stories.values().any(|story| story_any(story, 0, &chosen)) + } + /// Placements outside the ordinary document-spread frame walk used by preflight. pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool { self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 53308e1b8..617743840 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -642,6 +642,24 @@ impl Document { found } + /// Every item on document and parent spreads, in groups, and among the objects embedded in + /// stories (anchored objects, notes and table cells): walks that must cover every placement + /// use this, not [`Document::all_items`]. + pub fn placed_items(&self) -> Vec<&Item> { + let mut v = Vec::new(); + for sp in self.spreads.iter().chain(&self.parents) { + for it in &sp.items { + it.walk(&mut |i| v.push(i)); + } + } + for st in self.stories.values() { + for o in &st.objects { + o.item.walk(&mut |i| v.push(i)); + } + } + v + } + /// Every item id (pre-order) on document spreads. pub fn all_items(&self) -> Vec { let mut v = Vec::new(); diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f019976f2..bd1fba101 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -671,20 +671,27 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I } /// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. +/// +/// Every placement counts, including parent-page and anchored ones, so a choice is never +/// silently replaced by the file's defaults. fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { - let ids: Vec = - d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| it.has_pdf_layer_override() && it.graphic().is_some())).collect(); - if ids.is_empty() { + let choices: Vec<_> = d + .placed_items() + .into_iter() + .filter(|it| it.has_pdf_layer_override()) + .filter_map(|it| { + let g = it.graphic()?; + let a = d.assets.get(&g.asset).filter(|a| designcraft_render::is_pdf(&a.data))?; + Some((it.id, g.clone(), a.clone(), it.pdf_hidden_layers.clone())) + }) + .collect(); + if choices.is_empty() { return Ok((None, 0)); } let mut out = d.clone(); + let mut images = std::collections::HashMap::new(); let mut n = 0; - for id in ids { - let Some((g, hidden)) = out.item(id).and_then(|it| Some((it.graphic()?.clone(), it.pdf_hidden_layers.clone()))) else { continue }; - let Some(a) = out.assets.get(&g.asset).cloned() else { continue }; - if !designcraft_render::is_pdf(&a.data) { - continue; - } + for (id, g, a, hidden) in choices { // The whole page at 300 ppi (the graphic may show only a box of it). let shown = designcraft_render::shown_box(&a); let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); @@ -705,11 +712,19 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option Result { /// Each placement gets its own embedded variant so two frames sharing a PDF can /// choose different layers. The live document and original linked bytes stay untouched. +/// +/// Every placement counts, including parent-page and anchored ones: a choice that the copy +/// cannot carry is rejected rather than exported as the file's defaults. fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result> { - let ids: Vec<_> = doc - .all_items() + let choices: Vec<_> = doc + .placed_items() .into_iter() - .filter(|id| { - doc.item(*id).is_some_and(|it| { - it.has_pdf_layer_override() - && it.graphic().and_then(|g| doc.assets.get(&g.asset)).is_some_and(|a| designcraft_render::is_pdf(&a.data)) - }) + .filter(|it| it.has_pdf_layer_override()) + .filter_map(|it| { + let asset = it.graphic().and_then(|g| doc.assets.get(&g.asset)).filter(|a| designcraft_render::is_pdf(&a.data))?; + Some((it.id, asset, it.pdf_hidden_layers.clone())) }) .collect(); - if ids.is_empty() { + if choices.is_empty() { return Ok(None); } if !embed { return Err(EngineError::Other("PDF layer choices require embedded images in IDML export".into())); } let mut out = doc.clone(); - for id in ids { - let Some(it) = doc.item(id) else { continue }; - let Some(asset) = it.graphic().and_then(|g| doc.assets.get(&g.asset)) else { continue }; - let bytes = designcraft_render::pdf_hide_layers(&asset.data, &it.pdf_hidden_layers) + let mut variants = std::collections::HashMap::new(); + for (id, asset, hidden) in choices { + let bytes = designcraft_render::pdf_hide_layers(&asset.data, &hidden) .ok_or_else(|| EngineError::Other(format!("{}: PDF layer choices could not be applied", asset.name)))?; let mut variant = (**asset).clone(); variant.id = designcraft_doc::AssetId(out.alloc()); @@ -71,14 +72,22 @@ fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result