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 01/17] 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 02/17] 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 03/17] 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 f5e1bda7ccdf3ed291c9bc10eacfcd85d762475e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 01:14:04 -0700 Subject: [PATCH 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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 09/17] 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 10/17] =?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 11/17] 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 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 12/17] 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 13/17] 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 14/17] 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 15/17] 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 16/17] 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 17/17] 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 |