diff --git a/ROADMAP.md b/ROADMAP.md index 001523158..92786365f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -352,6 +352,7 @@ Newest first. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. +- **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 248e4d99c..947e765a8 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -713,10 +713,10 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { 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 (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, under.map_or(g.start, |pg| g.origin(pg)))); + 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 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 d41cf5976..bf8977e90 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -6611,7 +6611,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 d3587c943..eee80ecde 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 039d1e562..0f1632363 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2995,8 +2995,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 d00c76e74..970567e7c 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -11,6 +11,7 @@ mod assets; mod clipping; mod decorations; mod facing_ltr; +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)); +}