diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index 862a1cda..d1a77d78 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -42,6 +42,77 @@ impl Default for Columns { } } +/// The most columns a page can have, as in InDesign. +pub const MAX_COLUMNS: u32 = 216; + +/// How far, in points, the first and last custom column positions may sit from the margin box's +/// edges: room for float noise in files and page arithmetic, far less than any dragged guide. +pub const COLUMN_POSITION_TOLERANCE: f64 = 0.01; + +/// Whether `positions` are usable custom column positions for `count` columns across a margin box +/// `margin_width` wide, in IDML's `ColumnsPositions` form: each column's left and right edge as an +/// offset from the margin box's left edge, so `2 × count` finite numbers, ascending across the +/// whole list, the first at 0 and the last at `margin_width` (within +/// [`COLUMN_POSITION_TOLERANCE`]). +pub fn valid_column_positions(positions: &[f64], count: u32, margin_width: f64) -> bool { + (1..=MAX_COLUMNS).contains(&count) + && positions.len() == 2 * count as usize + && margin_width.is_finite() + && positions.iter().all(|v| v.is_finite()) + && positions.windows(2).all(|w| matches!(w, [a, b] if a <= b)) + && positions.first().is_some_and(|v| v.abs() <= COLUMN_POSITION_TOLERANCE) + && positions.last().is_some_and(|v| (v - margin_width).abs() <= COLUMN_POSITION_TOLERANCE) +} + +impl Columns { + /// The custom positions when they are valid for a margin box `margin_width` wide (see + /// [`valid_column_positions`]). Anything else means the columns are evenly spaced: a stored set + /// that no longer fits (a page resized after the drag, a hostile file) is not drawn. + pub fn valid_positions(&self, margin_width: f64) -> Option<&[f64]> { + let positions = self.positions.as_deref()?; + valid_column_positions(positions, self.count, margin_width).then_some(positions) + } + + /// Every column's left and right edge as offsets from the margin box's left edge: the valid + /// custom positions, else the even split. This is what IDML writes as `ColumnsPositions`. + pub fn edges(&self, margin_width: f64) -> Vec { + let w = if margin_width.is_finite() { margin_width.max(0.0) } else { 0.0 }; + match self.valid_positions(w) { + Some(p) => p.iter().map(|&v| inside(v, w)).collect(), + None => column_rects(Rect::new(0.0, 0.0, w, 0.0), self.count.max(1), self.gutter).iter().flat_map(|r| [r.x0, r.x1]).collect(), + } + } + + /// The column edges (as [`Columns::edges`]) with gutter `gutter` (0 is the one between the first + /// two columns) moved so its left edge sits at `new_left`, an offset from the margin box's left + /// edge. Both of the gutter's guides move, so it keeps its width, and it stops at its + /// neighbouring gutters or the margins: no column gets a negative width. `None` when there is + /// no such gutter, `new_left` is not finite, or the result would not be valid. + pub fn with_gutter_moved(&self, margin_width: f64, gutter: usize, new_left: f64) -> Option> { + if !new_left.is_finite() { + return None; + } + let mut pos = self.edges(margin_width); + let i = gutter.checked_mul(2)?.checked_add(1)?; + let &[lo, left, right, hi] = pos.get(i - 1..i.checked_add(3)?)? else { + return None; + }; + let width = right - left; + let at = new_left.min(hi - width).max(lo); + let [l, r] = pos.get_mut(i..i + 2)? else { + return None; + }; + *l = at; + *r = (at + width).min(hi); + valid_column_positions(&pos, self.count, margin_width).then_some(pos) + } +} + +/// `v` limited to `0..=w` without `clamp`, which panics when `w` is NaN. +fn inside(v: f64, w: f64) -> f64 { + v.max(0.0).min(w) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Orientation { @@ -250,17 +321,23 @@ impl Page { let b = self.bounds(); Rect::new(b.x0 + l, b.y0 + self.margins.top, (b.x1 - r).max(b.x0 + l), (b.y1 - self.margins.bottom).max(b.y0 + self.margins.top)) } - /// Column rects within the margins. + /// Column rects within the margins: the custom positions when the column guides have been + /// dragged and still fit the margin box (see [`valid_column_positions`]), otherwise an even + /// split. pub fn column_rects(&self) -> Vec { let m = self.margin_rect(); - column_rects(m, self.columns.count.max(1), self.columns.gutter) + let w = m.width(); + match self.columns.valid_positions(w) { + Some(p) => p.as_chunks::<2>().0.iter().map(|&[a, b]| Rect::new(m.x0 + inside(a, w), m.y0, m.x0 + inside(b, w), m.y1)).collect(), + None => column_rects(m, self.columns.count.max(1), self.columns.gutter), + } } } /// Split `area` into `n` equal columns separated by `gutter`. pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec { // The public allocator also receives native/imported options, outside command validation. - let n = n.clamp(1, 216) as usize; + let n = n.clamp(1, MAX_COLUMNS) as usize; let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0); (0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect() } @@ -450,6 +527,81 @@ mod tests { assert!((cols[1].x0 - cols[0].x1 - 12.0).abs() < 1e-9); } + /// Dragged column guides place the columns; a set that cannot mean anything falls back to the + /// even split rather than producing nothing or a column outside the margins (#1096). + #[test] + fn custom_column_positions_place_the_columns() { + let mut p = page(PageSide::Single, 0.0); + let m = p.margin_rect(); + let w = m.width(); + let even = p.column_rects(); + + p.columns.positions = Some(vec![0.0, 100.0, 130.0, w]); + assert_eq!(p.column_rects(), vec![Rect::new(m.x0, m.y0, m.x0 + 100.0, m.y1), Rect::new(m.x0 + 130.0, m.y0, m.x1, m.y1)]); + // Float noise at the edges is absorbed and kept inside the box. + p.columns.positions = Some(vec![-0.001, 100.0, 130.0, w + 0.001]); + assert_eq!((p.column_rects()[0].x0, p.column_rects()[1].x1), (m.x0, m.x1)); + + let huge: Vec = (0..10_000).map(f64::from).collect(); + let unusable = [ + vec![], + vec![10.0], + vec![0.0, w], // one column's worth for two columns + vec![0.0, 100.0, 130.0, 200.0, 230.0, w], // three columns' worth + vec![0.0, 100.0, 90.0, w], // descending across pairs + vec![0.0, 130.0, 100.0, w], // an inverted gutter + vec![0.0, f64::NAN, 130.0, w], + vec![0.0, 100.0, f64::INFINITY, w], + vec![5.0, 100.0, 130.0, w], // not from the left margin + vec![0.0, 100.0, 130.0, w + 500.0], // a page narrowed after the drag + vec![0.0, 100.0, 130.0, w - 50.0], // a page widened after the drag + huge, + ]; + for bad in unusable { + p.columns.positions = Some(bad.clone()); + assert_eq!(p.column_rects(), even, "{:?}", bad.iter().take(6).collect::>()); + assert_eq!(p.columns.edges(w), even.iter().flat_map(|r| [r.x0 - m.x0, r.x1 - m.x0]).collect::>()); + } + p.columns.positions = None; + assert_eq!(p.column_rects(), even); + + // A count outside 1..=216 has no valid positions, however many are given. + for count in [0, 217, u32::MAX] { + let c = Columns { count, gutter: 0.0, positions: Some(vec![0.0, w]) }; + assert!(c.valid_positions(w).is_none(), "{count}"); + } + } + + /// Moving a gutter moves both its guides, keeps its width and stops at the neighbouring gutters + /// and the margins; the result is valid custom positions. + #[test] + fn with_gutter_moved_moves_a_gutter_pair_and_clamps() { + // Three columns across 450 with gutter 15: 0 140 155 295 310 450. + let c = Columns { count: 3, gutter: 15.0, positions: None }; + let even = c.edges(450.0); + assert_eq!(even, vec![0.0, 140.0, 155.0, 295.0, 310.0, 450.0]); + + assert_eq!(c.with_gutter_moved(450.0, 0, 100.0), Some(vec![0.0, 100.0, 115.0, 295.0, 310.0, 450.0])); + // Past the left margin: the first column closes to nothing. + assert_eq!(c.with_gutter_moved(450.0, 0, -50.0), Some(vec![0.0, 0.0, 15.0, 295.0, 310.0, 450.0])); + // Past the next gutter: the middle column closes to nothing, the next gutter stays put. + assert_eq!(c.with_gutter_moved(450.0, 0, 400.0), Some(vec![0.0, 280.0, 295.0, 295.0, 310.0, 450.0])); + // The last gutter stops at the right margin. + assert_eq!(c.with_gutter_moved(450.0, 1, 1000.0), Some(vec![0.0, 140.0, 155.0, 435.0, 450.0, 450.0])); + + // It starts from custom positions already there, and its result is accepted as custom. + let moved = Columns { positions: c.with_gutter_moved(450.0, 0, 100.0), ..c.clone() }; + assert!(moved.valid_positions(450.0).is_some()); + assert_eq!(moved.with_gutter_moved(450.0, 1, 200.0), Some(vec![0.0, 100.0, 115.0, 200.0, 215.0, 450.0])); + + // No such gutter, or no position to move it to. + assert_eq!(c.with_gutter_moved(450.0, 2, 100.0), None); + assert_eq!(c.with_gutter_moved(450.0, usize::MAX, 100.0), None); + assert_eq!(c.with_gutter_moved(450.0, 0, f64::NAN), None); + assert_eq!(Columns { count: 1, gutter: 12.0, positions: None }.with_gutter_moved(450.0, 0, 100.0), None); + assert_eq!(c.with_gutter_moved(f64::NAN, 0, 100.0), None); + } + #[test] fn spread_spine_and_hit() { let s = Spread { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 615e7c1d..667bb927 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -97,11 +97,15 @@ fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) { } _ => {} } + // Setting a count or a gutter re-divides the columns evenly, as InDesign's Margins and + // Columns does: dragged column positions are what the new value replaces. if let Some(c) = p.get("columns").and_then(Value::as_u64) { pg.columns.count = (c as u32).clamp(1, 216); + pg.columns.positions = None; } if let Some(g) = p.get("gutter").and_then(Value::as_f64) { pg.columns.gutter = g.max(0.0); + pg.columns.positions = None; } } @@ -1650,6 +1654,46 @@ mod adjust_tests { // An unknown parent is an error, not a silent no-op. assert!(s.execute("layout.marginsAndColumns", &json!({"parent": 9, "margins": 10})).is_err()); } + + /// Margins and Columns re-divides the columns: a new count or gutter replaces column guides + /// that were dragged (an imported Custom columns page), rather than being ignored by them. + #[test] + fn a_new_column_count_replaces_dragged_column_positions() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + // There is no command for dragging a column guide yet, so set what an import would leave. + let drag = |s: &mut Session| { + s.edit(|d, _| { + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0]; + pg.columns = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) }; + Ok(()) + }) + .unwrap(); + }; + + drag(&mut s); + let dragged = s.doc().unwrap().doc.page(0).unwrap().column_rects(); + assert_eq!(dragged.len(), 2); + assert!((dragged[0].x1 - dragged[0].x0 - 100.0).abs() < 1e-6, "the dragged gutter is in use: {dragged:?}"); + + // A new count: evenly divided again, and the stale positions are gone. + s.execute("layout.marginsAndColumns", &json!({"columns": 3})).unwrap(); + let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); + assert_eq!((pg.columns.count, pg.columns.positions.clone()), (3, None)); + assert_eq!(pg.column_rects().len(), 3); + + // So does a new gutter on its own. + drag(&mut s); + s.execute("layout.marginsAndColumns", &json!({"gutter": 24})).unwrap(); + let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); + assert_eq!(pg.columns.positions, None); + assert!((pg.column_rects()[1].x0 - pg.column_rects()[0].x1 - 24.0).abs() < 1e-6); + + // Margins alone leave them alone: the offsets are relative to the margin box. + drag(&mut s); + s.execute("layout.marginsAndColumns", &json!({"margins": 72})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 130.0, 540.0])); + } } #[cfg(test)] diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 86fef8e4..bd1d117e 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -8,7 +8,7 @@ use designcraft_doc::{ CharAttrs, CharFormat, ColumnsKind, Content, Document, Item, ListType, PageSide, ParaAttrs, ParaFormat, Rule, Shape, SpanColumns, Spread, Story, TextFrameOptions, story as st, }; -use designcraft_geom::{Affine, PathData, Rect}; +use designcraft_geom::{Affine, PathData}; use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, escape_id, num, pt}; use crate::xml::{El, Node, document, document_with_pi}; @@ -2901,8 +2901,9 @@ impl Group { fn margin_el(pg: &designcraft_doc::Page) -> El { let m = pg.margin_rect(); - let cols = designcraft_doc::column_rects(Rect::new(0.0, 0.0, m.width(), m.height()), pg.columns.count.max(1), pg.columns.gutter); - let pos: Vec = cols.iter().flat_map(|c| [num(c.x0), num(c.x1)]).collect(); + // Dragged column guides that still fit the margin box are written as they stand, so they + // survive the round trip; otherwise the even split, which InDesign writes for unmoved columns. + let pos: Vec = pg.columns.edges(m.width()).into_iter().map(num).collect(); El::new("MarginPreference") .attr("ColumnCount", pg.columns.count.max(1)) .attr("ColumnGutter", num(pg.columns.gutter)) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d770aef7..271eba14 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2534,9 +2534,15 @@ impl<'r> Importer<'r> { .map(|q| (q.margins, q.columns.clone())) }; let (margins, columns) = match (p.find("MarginPreference"), &from_parent) { - (Some(m), _) => margins_of(m), + (Some(m), _) => { + let (margins, columns) = margins_of(m); + (margins, fit_columns(columns, &margins, r.width())) + } (None, Some(own)) => own.clone(), - (None, None) => self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())), + (None, None) => { + let (margins, columns) = self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())); + (margins, fit_columns(columns, &margins, r.width())) + } }; let local_margins = from_parent.is_some_and(|(m, c)| m != margins || c != columns); out_pages.push(Page { @@ -3465,10 +3471,39 @@ fn composer_in(name: &str) -> designcraft_doc::Composer { fn margins_of(m: &El) -> (Margins, Columns) { let g = |k: &str, d: f64| m.num(k).filter(|v| v.is_finite()).unwrap_or(d); - ( - Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) }, - Columns { count: g("ColumnCount", 1.0).clamp(1.0, 216.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None }, - ) + let margins = Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) }; + let count = g("ColumnCount", 1.0).clamp(1.0, f64::from(designcraft_doc::MAX_COLUMNS)) as u32; + let gutter = g("ColumnGutter", 12.0); + let positions = m.get("ColumnsPositions").and_then(|s| custom_columns(s, count, gutter)); + (margins, Columns { count, gutter, positions }) +} + +/// `ColumnsPositions` when the column guides have been dragged off the even split: `2 × count` +/// offsets from the margin box's left edge, valid as [`designcraft_doc::valid_column_positions`] +/// over the box they span. InDesign writes the attribute whether or not they have been moved, so +/// an even set is dropped: `positions` means "custom", and keeping an even copy would freeze the +/// columns against a later change of count or gutter. Anything malformed is the even split; at +/// most one number past `2 × count` is parsed, so a long list costs nothing. +fn custom_columns(s: &str, count: u32, gutter: f64) -> Option> { + let want = 2 * count as usize; + let pos = s.split_whitespace().take(want + 1).map(|v| v.parse::().ok()).collect::>>()?; + let width = *pos.last()?; + if !designcraft_doc::valid_column_positions(&pos, count, width) { + return None; + } + let even = Columns { count, gutter, positions: None }.edges(width); + // The tolerance only absorbs float noise in InDesign's arithmetic; a dragged guide moves far more. + pos.iter().zip(&even).any(|(a, b)| (a - b).abs() > 1e-4).then_some(pos) +} + +/// `columns` with custom positions only when they fit the margin box of a `page_width`-wide page +/// with these margins (the box's width doesn't depend on which side of the spine it is). +fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Columns { + let width = (page_width - margins.inside - margins.outside).max(0.0); + if columns.valid_positions(width).is_none() { + columns.positions = None; + } + columns } /// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it. @@ -3995,6 +4030,48 @@ mod page_column_import_safety_tests { assert_eq!(margins, Margins::uniform(36.)); } } + + /// Hostile `ColumnsPositions` are the even split: never half-read, never a panic, and a long + /// list is not parsed past what `ColumnCount` allows (#1096). + #[test] + fn hostile_column_positions_fall_back_to_the_even_split() { + let positions = |count: &str, raw: &str| { + let m = El::new("MarginPreference").attr("ColumnCount", count).attr("ColumnGutter", "20").attr("ColumnsPositions", raw); + margins_of(&m).1.positions + }; + assert_eq!(positions("2", "0 150 170 450"), Some(vec![0., 150., 170., 450.])); + let huge = (0..10_000).map(|i| i.to_string()).collect::>().join(" "); + for (count, raw) in [ + ("2", "0 215 235 450"), // the even split + ("2", "0 150 170"), // odd + ("2", "0 450"), // too few for the count + ("2", "0 100 120 200 220 450"), // too many for the count + ("2", "0 150 90 450"), // descending + ("2", "0 NaN 170 450"), + ("2", "0 inf 170 450"), + ("2", "0 150 170 1e309"), + ("2", "0 x 170 450"), + ("2", "10 150 170 450"), // not from the margin + ("2", ""), + ("2", huge.as_str()), + ("4000000000", huge.as_str()), // the count is capped at 216, so is the list + ("216", huge.as_str()), + ("NaN", "0 150 170 450"), + ] { + assert_eq!(positions(count, raw), None, "{count}: {}", raw.get(..raw.len().min(40)).unwrap_or("")); + } + // The cap itself: 216 one-point columns with no gutter are the even split; one edge moved + // makes them custom. + let gapless = |raw: &str| { + let m = El::new("MarginPreference").attr("ColumnCount", "216").attr("ColumnGutter", "0").attr("ColumnsPositions", raw); + margins_of(&m).1.positions + }; + let edges = |first_right: &str| { + (0..432).map(|i| if i == 1 { first_right.to_string() } else { ((i + 1) / 2).to_string() }).collect::>().join(" ") + }; + assert_eq!(gapless(&edges("1")), None); + assert_eq!(gapless(&edges("0.5")).map(|p| p.len()), Some(432)); + } } #[cfg(test)] diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index 38c4fabb..8f7b22ff 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -17,7 +17,8 @@ //! ## Supported subset //! Swatches (process/spot CMYK, RGB and Lab colours, tints, linear/radial gradients), paragraph, //! character and object styles (with `BasedOn` and style groups), fonts, document preferences -//! (page size, facing pages, bleed/slug, margins and columns, units, grids), layers, sections, +//! (page size, facing pages, bleed/slug, margins and columns including dragged column positions, +//! units, grids), ruler guides, layers, sections, //! parent (master) spreads and applied parents, spreads and pages, page items (`TextFrame`, //! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text //! frame options (with column rules), text wrap, transparency (opacity, blend mode, drop shadow, diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 81cebff7..cf9ce28a 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4194,6 +4194,75 @@ mod liquid_page_rules { } } +/// A dragged column gutter (InDesign's Custom columns) survives an IDML import, and an even set — +/// which InDesign writes for every page, moved or not — does not pretend to be custom (#1096). +#[test] +fn custom_column_positions_import_and_round_trip() { + // Page 1 of SPREAD is 500 wide with Left=20 Right=30, so its margin box is 450 wide and + // starts at x=30. Two columns with gutter 20 split evenly as "0 215 235 450". + let with = |cols: &str| { + let spread = SPREAD.replace( + r#""#, + &format!( + r#""# + ), + ); + import_idml_with( + &zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ]), + &|_| None, + ) + .unwrap() + }; + let rects = |d: &designcraft_doc::Document| d.spreads[0].pages[0].column_rects().iter().map(|r| (r.x0, r.x1)).collect::>(); + + // Dragged: the gutter sits at 150..170 instead of 215..235, and the columns follow it. + let d = with(r#"ColumnsPositions="0 150 170 450""#); + assert_eq!(d.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0])); + assert_eq!(rects(&d), vec![(30.0, 180.0), (200.0, 480.0)]); + + // The even set is dropped, so a later change of count or gutter is not frozen out. The + // geometry is the same either way, which is what makes keeping it pointless. + let even = with(r#"ColumnsPositions="0 215 235 450""#); + assert_eq!(even.spreads[0].pages[0].columns.positions, None); + assert_eq!(rects(&even), vec![(30.0, 245.0), (265.0, 480.0)]); + // Including when InDesign's own arithmetic leaves the last bits different. + assert_eq!(with(r#"ColumnsPositions="0 215.00000000000003 234.99999999999997 450""#).spreads[0].pages[0].columns.positions, None); + + // Malformed sets are ignored rather than half-read: an odd count, three columns' worth for a + // count of two, a descending pair, a NaN, a set that stops short of the page's margin box, and + // a list far longer than any count allows. Each page keeps the even split. + let huge = format!(r#"ColumnsPositions="{}""#, (0..10_000).map(|i| i.to_string()).collect::>().join(" ")); + for bad in [ + r#"ColumnsPositions="0 150 170""#, + r#"ColumnsPositions="0 100 120 200 220 450""#, + r#"ColumnsPositions="0 150 90 450""#, + r#"ColumnsPositions="0 x 170 450""#, + r#"ColumnsPositions="0 NaN 170 450""#, + r#"ColumnsPositions="0 150 170 400""#, + r#"ColumnsPositions="""#, + huge.as_str(), + ] { + let d = with(bad); + assert_eq!(d.spreads[0].pages[0].columns.positions, None, "{}", bad.get(..bad.len().min(60)).unwrap_or("")); + assert_eq!(rects(&d), rects(&even)); + } + + // And they come back from an export, so the round trip keeps the dragged gutter. + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(back.spreads[0].pages[0].columns.positions, Some(vec![0.0, 150.0, 170.0, 450.0])); + assert_eq!(rects(&back), rects(&d)); + // A page nobody dragged still exports the even split, not a frozen copy of it. + assert_eq!(import_idml(&export_idml(&even)).unwrap().spreads[0].pages[0].columns.positions, None); +} + /// #1096: a ruler guide inside the gutter of a two-column page, written the way InDesign writes /// it (a `` child after its `MarginPreference`, with `Properties`/`GuideColor`, `PageIndex` /// and `GuideZone`), in a document without facing pages. A spread guide sits beside it.