Merge #1704: Text frames: a custom baseline grid keeps its start and increment through IDML

# Conflicts:
#	crates/compose/src/lib.rs
#	crates/idml/src/tests.rs
This commit is contained in:
aiongg
2026-10-24 05:52:12 +00:00
8 changed files with 114 additions and 9 deletions
+1
View File
@@ -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.
+2 -2
View File
@@ -713,10 +713,10 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
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) => {
+1 -1
View File
@@ -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<Line>, (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.
+31 -1
View File
@@ -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<FrameBaselineGrid>,
/// 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<PathType>,
}
/// 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<FrameBaselineGrid> 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::<TextFrameOptions>(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)));
+3 -3
View File
@@ -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)),
);
}
}
+4 -2
View File
@@ -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((
+1
View File
@@ -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;
+71
View File
@@ -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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document {NS} Self="d" StoryList="s1" Name="grid.indd"><idPkg:Graphic src="Resources/Graphic.xml"/><idPkg:Styles src="Resources/Styles.xml"/><idPkg:Preferences src="Resources/Preferences.xml"/><Layer Self="L1" Name="Layer 1" Visible="true" Locked="false"/><idPkg:Spread src="Spreads/Spread_sp1.xml"/><idPkg:Story src="Stories/Story_s1.xml"/></Document>"#
);
let prefs = format!(r#"<idPkg:Preferences {NS}><DocumentPreference PageWidth="400" PageHeight="600" FacingPages="false"/></idPkg:Preferences>"#);
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#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
let spread = format!(
r#"<idPkg:Spread {NS}><Spread Self="sp1" PageCount="1" ItemTransform="1 0 0 1 0 0"><Page Self="p1" GeometricBounds="0 0 600 400" ItemTransform="1 0 0 1 0 -300"/>
<TextFrame Self="t1" ParentStory="s1" PreviousTextFrame="n" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 0 -300"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties><TextFramePreference InsetSpacing="0"/><BaselineFrameGridOption UseCustomBaselineFrameGrid="true" StartingOffsetForBaselineFrameGrid="6" BaselineFrameGridIncrement="14"/></TextFrame>
</Spread></idPkg:Spread>"#
);
let story = format!(
r#"<idPkg:Story {NS}><Story Self="s1"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]" GridAlignment="AlignToBaseline"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>One</Content><Br/><Content>Two</Content><Br/><Content>Three</Content><Br/><Content>Four</Content></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#
);
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<f64> {
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));
}