mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
* IDML: keep column guides that were dragged off centre (#1096)
InDesign records dragged column guides in `MarginPreference ColumnsPositions`,
as pairs of offsets from the margin box's left edge. The importer discarded them
and divided the columns evenly, so a page whose gutter the designer had moved
came back with it in the middle: on a 450 pt margin box, `0 150 170 450` was
read as columns at 215 and 235.
`Columns::positions` already existed, documented as "Custom column positions
(after dragging column guides)", but nothing set it and nothing read it. Now
`Page::column_rects` returns the custom rects when they are set — that one
method is what the canvas guides, snapping, threading and the first frame of a
placed story all go through, so they follow the dragged gutter — the importer
fills it, and the exporter writes the page's real positions instead of
recomputing an even split, so the round trip keeps them.
`layout.marginsAndColumns` clears them when a count or gutter is set, which is
what InDesign's Margins and Columns does. Without that the stale positions would
outrank the new value and the dialog would look broken.
Verified against InDesign-authored IDML rather than assumed, which settled two
things. The attribute is written whether or not anything was dragged — a real
page's values are exactly its even split — so an even set is dropped rather than
marking every imported page custom and freezing its columns; the tolerance for
that comparison only absorbs float noise, about 1e-14 pt on that page. And
`<Guide>` is a `<Page>` child, not a `<Spread>` one, which is where this
importer already looks: the reporter's missing ruler guide was 099fbd2, landed
the day the ticket was filed, not a second bug.
That commit left `lib.rs` still listing guides as ignored on import; corrected.
* IDML: custom column positions are validated and round-trip (#1096)
Columns::positions are used only when they are 2 x count finite edges,
ascending from 0 to the margin box width (within 0.01 pt); anything else
is the even split, in Page::column_rects, on IDML import (bounded parse,
checked against the page's margin box) and on IDML export.
Columns::with_gutter_moved moves one gutter's pair of guides between its
neighbours for the coming column-guide command.
ROADMAP: drop four progress-log lines the #1121 merge duplicated and move
its line to the top.
* IDML: a page without its own margins checks the default's column positions against its width (#1096)
* ROADMAP: the progress line lands in one commit after the guide PRs
---------
Co-authored-by: Hamza <mail.hamzanaveed@gmail.com>
This commit is contained in:
+155
-3
@@ -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<f64> {
|
||||
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<Vec<f64>> {
|
||||
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<Rect> {
|
||||
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<Rect> {
|
||||
// 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<f64> = (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::<Vec<_>>());
|
||||
assert_eq!(p.columns.edges(w), even.iter().flat_map(|r| [r.x0 - m.x0, r.x1 - m.x0]).collect::<Vec<_>>());
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<String> = 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<String> = 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))
|
||||
|
||||
@@ -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<Vec<f64>> {
|
||||
let want = 2 * count as usize;
|
||||
let pos = s.split_whitespace().take(want + 1).map(|v| v.parse::<f64>().ok()).collect::<Option<Vec<f64>>>()?;
|
||||
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::<Vec<_>>().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::<Vec<_>>().join(" ")
|
||||
};
|
||||
assert_eq!(gapless(&edges("1")), None);
|
||||
assert_eq!(gapless(&edges("0.5")).map(|p| p.len()), Some(432));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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#"<MarginPreference ColumnCount="3" ColumnGutter="12" Top="10" Bottom="10" Left="20" Right="30"/>"#,
|
||||
&format!(
|
||||
r#"<MarginPreference ColumnCount="2" ColumnGutter="20" Top="10" Bottom="10" Left="20" Right="30" ColumnDirection="Horizontal" {cols}/>"#
|
||||
),
|
||||
);
|
||||
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::<Vec<_>>();
|
||||
|
||||
// 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::<Vec<_>>().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 `<Page>` child after its `MarginPreference`, with `Properties`/`GuideColor`, `PageIndex`
|
||||
/// and `GuideZone`), in a document without facing pages. A spread guide sits beside it.
|
||||
|
||||
Reference in New Issue
Block a user