From df6ba617d3e6d2a22a26ed4436f6be7f21f54625 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 04:58:04 +0000 Subject: [PATCH] Text frames: a custom baseline grid keeps its start and increment through IDML --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 4 +- crates/compose/src/tests.rs | 2 +- crates/doc/src/item.rs | 32 ++++++++++++- crates/idml/src/export.rs | 6 +-- crates/idml/src/import.rs | 6 ++- crates/idml/src/tests.rs | 1 + crates/idml/src/tests/frame_grid.rs | 71 +++++++++++++++++++++++++++++ 8 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 crates/idml/src/tests/frame_grid.rs diff --git a/ROADMAP.md b/ROADMAP.md index e4d94421d..43e8060d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -350,6 +350,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Text frames: a custom baseline grid (Text Frame Options › Baseline Options) from an IDML file sets lines from its start by its increment. The composer read the two values in the other order, so lines landed on the wrong grid. 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. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ebf1e377b..56b79de7b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -712,10 +712,10 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { _ => None, }; let g = &doc.settings.baseline_grid; - let (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, g.start)); + let (start, inc) = tf.options.baseline_grid.map_or((g.start, 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 grid = (inc > 0.0).then_some((start - ty, inc)); + 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 { Some(pt) => { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b73806d27..e31baddf2 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -6610,7 +6610,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((12.0, 0.0)); + tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid { start: 0.0, increment: 12.0 }); } edit(&mut d, sid); // Vertical frames have no baseline grid. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index cfb13dc53..ab345e371 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -452,12 +452,34 @@ pub struct TextFrameOptions { pub column_rule_bottom_inset: f64, /// Use a custom baseline grid for this frame. #[serde(skip_serializing_if = "Option::is_none")] - pub baseline_grid: Option<(f64, f64)>, + pub baseline_grid: Option, /// Type on a Path: the text runs along the item's path instead of filling it. #[serde(skip_serializing_if = "Option::is_none")] 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)")] +pub struct FrameBaselineGrid { + /// The first grid line, down from the top of the page. + pub start: f64, + /// The distance between grid lines; 0 or less turns the grid off. + pub increment: f64, +} + +impl From<(f64, f64)> for FrameBaselineGrid { + fn from((start, increment): (f64, f64)) -> Self { + FrameBaselineGrid { start, increment } + } +} + +impl From for (f64, f64) { + fn from(g: FrameBaselineGrid) -> Self { + (g.start, g.increment) + } +} + /// Type on a Path Options. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -1662,6 +1684,14 @@ mod tests { assert_eq!(*c, ClippingPath::new(ClippingKind::AlphaChannel, PathData::default())); } + #[test] + fn frame_baseline_grid_saves_as_start_then_increment() { + 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!(serde_json::to_value(&o).unwrap()["baselineGrid"], serde_json::json!([6.0, 14.0])); + } + #[test] fn bounds_and_text_area() { let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0))); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index df5a1a243..3d6b651ca 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2012,12 +2012,12 @@ impl<'a> Ex<'a> { } if let Content::Text(t) = &it.content { el.push(text_frame_pref(&t.options, &self.sw(&t.options.column_rule_color))); - if let Some((start, inc)) = t.options.baseline_grid { + if let Some(g) = t.options.baseline_grid { el.push( El::new("BaselineFrameGridOption") .attr("UseCustomBaselineFrameGrid", "true") - .attr("StartingOffsetForBaselineFrameGrid", num(start)) - .attr("BaselineFrameGridIncrement", num(inc)), + .attr("StartingOffsetForBaselineFrameGrid", num(g.start)) + .attr("BaselineFrameGridIncrement", num(g.increment)), ); } } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index b57210576..e799739c1 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2994,8 +2994,10 @@ impl<'r> Importer<'r> { if let Some(g) = self.with_style(e, "BaselineFrameGridOption") && g.get("UseCustomBaselineFrameGrid") == Some("true") { - options.baseline_grid = - Some((g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), g.num("BaselineFrameGridIncrement").unwrap_or(12.0))); + options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid { + start: g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), + increment: g.num("BaselineFrameGridIncrement").unwrap_or(12.0), + }); } it.content = Content::Text(TextFrame { story, options }); self.ctx.threads.push(( diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 35374a5e0..431491efe 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -10,6 +10,7 @@ use super::*; mod assets; mod clipping; mod decorations; +mod frame_grid; mod index_sort; mod liquid_guides; mod object_styles; diff --git a/crates/idml/src/tests/frame_grid.rs b/crates/idml/src/tests/frame_grid.rs new file mode 100644 index 000000000..93c081377 --- /dev/null +++ b/crates/idml/src/tests/frame_grid.rs @@ -0,0 +1,71 @@ +use super::*; + +const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="14.0""#; + +/// A page with one text frame at its top edge whose custom baseline grid starts at 6 pt with a +/// 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 { + let designmap = format!( + r#" +"# + ); + let prefs = format!(r#""#); + let pts: String = [(50.0, 0.0), (50.0, 300.0), (250.0, 300.0), (250.0, 0.0)] + .iter() + .map(|(x, y)| format!(r#""#)) + .collect(); + let spread = format!( + r#" +{pts} +"# + ); + let story = format!( + r#"One
Two
Three
Four
"# + ); + let bytes = zip_files(&[ + ("designmap.xml", &designmap), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", &prefs), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", &story), + ]); + import_idml_with(&bytes, &|_| None).unwrap() +} + +/// Each composed line's baseline below the frame top. +fn baselines(d: &Document) -> Vec { + let sid = *d.stories.keys().next().unwrap(); + let top = d.spreads[0].items[0].text_area().y0; + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.baseline - top).collect() +} + +#[test] +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 })); + let b = baselines(&d); + assert_eq!(b.len(), 4, "{b:?}"); + for y in &b { + let n = (y - 6.0) / 14.0; + assert!((n - n.round()).abs() < 1e-6 && n >= 0.0, "baseline {y} is on the 6 + n·14 grid: {b:?}"); + } + // The first line's ascent is past the 6 pt line, so it sits on the next one. + assert!((b[0] - 20.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn custom_frame_grid_round_trips() { + let d = frame_grid_doc(); + let bytes = export_idml(&d); + let spread = zip_text(&bytes, "Spreads/"); + assert!(spread.contains(r#"StartingOffsetForBaselineFrameGrid="6""#), "{spread}"); + assert!(spread.contains(r#"BaselineFrameGridIncrement="14""#), "{spread}"); + let back = import_idml(&bytes).unwrap(); + let grid = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid; + assert_eq!(grid(&back), grid(&d)); + assert_eq!(baselines(&back), baselines(&d)); +}