Anchored objects: Relative to Spine mirrors custom positions on right-hand pages (#1702)

* Anchored objects: Relative to Spine mirrors custom positions on right-hand pages

* Anchored objects: page side from the frame's spread position; footnote cache keyed by page side
This commit is contained in:
aiongg
2026-10-24 01:34:05 -04:00
committed by GitHub
parent 4823b59492
commit d8d7885172
11 changed files with 197 additions and 29 deletions
+28 -13
View File
@@ -583,6 +583,9 @@ pub struct FrameSpec {
pub grid: Option<(f64, f64)>,
/// The frame is on a left page (for towards/away-from-spine alignment).
pub left_page: bool,
/// The frame is on a right page of a facing spread (spine-relative anchored objects mirror
/// there; a single page has no spine).
pub right_page: bool,
/// The page and its margins in the frame's inner space (custom anchored objects).
pub page_rect: Option<(Rect, Rect)>,
}
@@ -662,7 +665,10 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
let loc = doc.find(fid);
let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue };
let Some(tf) = item.text_frame() else { continue };
let xf = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY) * item.xf;
let parent = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY);
let xf = parent * item.xf;
// Where the frame is on the spread (a group's transform included), to find its page.
let center_x = parent.transform_rect_bbox(item.bounds()).center().x;
let inv = xf.inverse();
let mut exclusions = Vec::new();
let spread_ref = loc.as_ref().map(|l| l.spread);
@@ -686,27 +692,27 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0
});
}
let (page_name, page, left_page) = match (spread_ref, spread) {
let (page_name, page, side) = match (spread_ref, spread) {
(Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => {
let c = item.bounds().center();
let pi = sp.page_at_x(c.x).unwrap_or(0);
let pi = sp.page_at_x(center_x).unwrap_or(0);
let abs = doc.first_page_of_spread(si) + pi;
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left))
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).map(|p| p.side))
}
(Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => {
let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into());
// The side of the parent page under the frame, so a folio there aligns to the spine of its own page.
let under = sp.page_at_x(item.bounds().center().x).and_then(|pi| sp.pages.get(pi));
let left = under.is_some_and(|p| p.side == designcraft_doc::PageSide::Left);
(Some(prefix), None, left)
let under = sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi));
(Some(prefix), None, under.map(|p| p.side))
}
_ => (None, None, false),
_ => (None, None, None),
};
let left_page = side == Some(designcraft_doc::PageSide::Left);
let right_page = side == Some(designcraft_doc::PageSide::Right);
let section_marker = page.map(|abs| section_marker(doc, abs));
// The page under the frame and its margins, in inner space (bounding boxes).
let page_rect = match (spread_ref, spread) {
(_, Some(sp)) => sp
.page_at_x(item.bounds().center().x)
.page_at_x(center_x)
.and_then(|pi| sp.pages.get(pi))
.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))),
_ => None,
@@ -773,6 +779,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
page,
grid,
left_page,
right_page,
page_rect,
});
}
@@ -2004,6 +2011,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
object_point,
ref_point,
keep_within_column,
spine_relative,
} => {
use designcraft_doc::anchored::AnchorRelative as R;
let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area);
@@ -2015,15 +2023,21 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)),
};
let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]);
let (rfx, rfy) = frac(*ref_point);
let (ofx, ofy) = frac(*object_point);
let (mut rfx, rfy) = frac(*ref_point);
let (mut ofx, ofy) = frac(*object_point);
let mut dx = *x_offset;
// The settings describe a left-hand page; a right-hand page mirrors them
// across the spine.
if *spine_relative && f.right_page {
(rfx, ofx, dx) = (1.0 - rfx, 1.0 - ofx, -dx);
}
let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx);
let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy);
let mut y = ry + y_offset - h * ofy;
if *keep_within_column {
y = y.clamp(col.y0, (col.y1 - h).max(col.y0));
}
Point::new(rx + x_offset - w * ofx, y)
Point::new(rx + dx - w * ofx, y)
}
};
}
@@ -4649,6 +4663,7 @@ mod column_allocation_safety_tests {
page: None,
grid: None,
left_page: false,
right_page: false,
page_rect: None,
outline: None,
}
+4 -2
View File
@@ -72,7 +72,7 @@ pub(crate) struct Notes {
pub key: Option<usize>,
pub placed: Vec<Placed>,
/// Composed footnote text by (index, label, width).
cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, NoteMetrics)>,
cache: HashMap<(usize, String, u64, bool, bool), (Arc<ComposedStory>, NoteMetrics)>,
notes: Vec<Arc<designcraft_doc::Footnote>>,
/// Footnotes span the columns of their frame.
span: bool,
@@ -153,7 +153,8 @@ impl Notes {
fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc<ComposedStory>, NoteMetrics) {
let o = &doc.footnote_options;
let label = format!("{}{}", o.label(self.numbers[k], true), o.separator);
let key = (k, label.clone(), w.to_bits());
// The page side sets towards/away-from-spine alignment and spine-relative anchored objects.
let key = (k, label.clone(), w.to_bits(), f.left_page, f.right_page);
if let Some(c) = self.cache.get(&key) {
return c.clone();
}
@@ -169,6 +170,7 @@ impl Notes {
page: f.page,
grid: None,
left_page: f.left_page,
right_page: f.right_page,
page_rect: None,
};
let nopts = ComposeOptions {
+6 -4
View File
@@ -121,7 +121,7 @@ impl CellLineExtent {
}
/// Compose a cell story at width `w` (content space, top-left at the origin).
fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout {
fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout {
let spec = FrameSpec {
id: ItemId(0),
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
@@ -134,6 +134,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left
page: opts.page,
grid: None,
left_page,
right_page,
page_rect: None,
};
let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
@@ -200,7 +201,7 @@ pub(crate) fn place_table(
let (nr, nc) = (table.nrows(), table.ncols());
let owners = table.owners();
let is_owner = |r: usize, c: usize| owners[r * nc + c] == (r, c);
let left_page = frames[cur.fi].left_page;
let side = (frames[cur.fi].left_page, frames[cur.fi].right_page);
// Compose every owner cell.
let mut layouts: Vec<Option<CellLayout>> = Vec::with_capacity(nr * nc);
for r in 0..nr {
@@ -212,7 +213,7 @@ pub(crate) fn place_table(
let cell = &table.cells[r * nc + c];
let cs = (cell.col_span.max(1) as usize).min(nc - c);
let w = colx[c + cs] - colx[c] - cell.insets[1] - cell.insets[3];
layouts.push(Some(compose_cell(doc, cell, w, opts, left_page)));
layouts.push(Some(compose_cell(doc, cell, w, opts, side)));
}
}
// Row heights.
@@ -813,7 +814,7 @@ mod height_tests {
}
fn measure(cell: &Cell, width: f64) -> CellLayout {
compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), false)
compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), (false, false))
}
fn compose_table(table: Table, height: f64) -> ComposedStory {
@@ -1061,6 +1062,7 @@ mod height_tests {
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
}),
);
assert!((measure(&cell, 92.0).content_h - original).abs() < 1e-6);
+86
View File
@@ -2752,6 +2752,7 @@ fn custom_anchors_do_not_change_bottom_baseline_fitting() {
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
};
d.story_mut(sid).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, pos));
let cs = compose_story(&d, sid, &ComposeOptions::default());
@@ -6740,6 +6741,7 @@ fn anchor_box(d: &mut Document, sid: StoryId, at: usize, x: f64, y: f64, y_rel:
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
};
d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(it, position));
}
@@ -6808,6 +6810,7 @@ fn anchored_object_wraps_with_its_contour_and_side() {
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
};
d.story_mut(a).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, position));
let cs = compose_story(&d, b, &ComposeOptions::default());
@@ -6821,6 +6824,70 @@ fn anchored_object_wraps_with_its_contour_and_side() {
assert!(top > mid + 5.0, "top {top}, middle {mid}");
}
/// Relative to Spine: a marginal note set up for a left-hand page (its right edge 12 pt left of
/// the column) mirrors on the right-hand page (its left edge 12 pt right of the column), so the
/// notes of a story that runs across a spread all sit in the outside margin. Placed as on a
/// left-hand page, the right page's note sat in the inside margin and reached across the spine.
#[test]
fn spine_relative_anchored_notes_stay_in_the_outside_margin_on_both_pages() {
use designcraft_doc::anchored::AnchorRelative as R;
let compose_spread = |spine_relative: bool| {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let lid = d.default_layer();
let sp = &d.spreads[1];
let (l, r) = (sp.pages[0].clone(), sp.pages[1].clone());
assert_eq!((l.side, r.side), (designcraft_doc::PageSide::Left, designcraft_doc::PageSide::Right));
// Body frames leave a 120 pt outside margin for the notes.
let left_rect = Rect::new(l.x + 120.0, 36.0, l.x + l.width - 36.0, 120.0);
let right_rect = Rect::new(r.x + 36.0, 36.0, r.x + r.width - 120.0, 600.0);
let text = [LOREM; 3].join(" ");
let (lf, sid) = d.add_text_frame(SpreadRef::Doc(1), left_rect, lid, &text, ParaFormat::default()).unwrap();
let (rf, _) = d.add_text_frame(SpreadRef::Doc(1), right_rect, lid, "", ParaFormat::default()).unwrap();
d.thread(lf, rf).unwrap();
let plain = compose_story(&d, sid, &ComposeOptions::default());
let second_line = |fid| plain.frame(fid).unwrap().lines[1].range.start;
let (at_left, at_right) = (second_line(lf), second_line(rf));
// Top-right corner of the note 12 pt left of the column's top-left, on the anchor's baseline.
let position = designcraft_doc::AnchorPosition::Custom {
x_relative: R::ColumnEdge,
y_relative: R::Anchor,
x_offset: -12.0,
y_offset: 0.0,
object_point: 2,
ref_point: 0,
keep_within_column: false,
spine_relative,
};
for at in [at_right, at_left] {
let note = designcraft_doc::Item::new(
ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 72.0, 40.0)),
);
d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position.clone()));
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
// Per page: the note's left and right edges, the column, and the page's left and right edges.
let placed = |fid, page: &designcraft_doc::Page| {
let ft = cs.frame(fid).unwrap();
assert_eq!(ft.objects.len(), 1);
let o = &ft.objects[0];
((o.origin.x, o.origin.x + o.size.0), ft.columns[0], (page.x, page.x + page.width))
};
(placed(lf, &l), placed(rf, &r))
};
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let (((lx0, lx1), lcol, (lp0, _)), ((rx0, rx1), rcol, (_, rp1))) = compose_spread(true);
assert!(near(lx1, lcol.x0 - 12.0) && lx0 >= lp0, "left page: note {lx0}..{lx1}, column {lcol:?}");
assert!(near(rx0, rcol.x1 + 12.0) && rx1 <= rp1, "right page: note {rx0}..{rx1}, column {rcol:?}");
// Without Relative to Spine both pages take the settings as written.
let (_, ((rx0, rx1), rcol, _)) = compose_spread(false);
assert!(near(rx1, rcol.x0 - 12.0), "right page as written: note {rx0}..{rx1}, column {rcol:?}");
}
const OBJECT_LEN: usize = designcraft_doc::OBJECT_MARK.len_utf8();
#[test]
@@ -7324,6 +7391,25 @@ fn parent_page_frames_know_which_side_of_the_spine_they_are_on() {
assert!(!frame_specs(&d, right_story)[0].left_page);
}
#[test]
fn a_grouped_frame_takes_the_side_of_the_page_its_group_moves_it_to() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let lid = d.default_layer();
let (l, r) = (d.spreads[1].pages[0].clone(), d.spreads[1].pages[1].clone());
let rect = Rect::new(l.x + 36.0, 36.0, l.x + l.width - 36.0, 300.0);
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, "Text", ParaFormat::default()).unwrap();
// The frame's own coordinates are on the left page; its group's transform puts it on the right one.
let mut g = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Group, designcraft_geom::PathData::default());
g.xf = designcraft_geom::Affine::translate((r.x - l.x, 0.0));
let sp = Arc::make_mut(&mut d.spreads[1]);
let at = sp.items.iter().position(|it| it.id == fid).unwrap();
g.content = designcraft_doc::Content::Group { items: vec![sp.items.remove(at)] };
sp.items.push(Arc::new(g));
let spec = &frame_specs(&d, sid)[0];
assert!(spec.right_page && !spec.left_page);
assert_eq!(spec.page, Some(2));
}
/// A right-to-left paragraph's first-line indent belongs at its start side, the right. Applied on
/// the left it moved the first line the wrong way — out of the frame for the hanging indent a
/// numbered list uses (right indent 10 mm, first line −10 mm), while the wrapped lines sat where
+5
View File
@@ -60,6 +60,11 @@ pub enum AnchorPosition {
/// Keep Within Top/Bottom Column Boundaries.
#[serde(default)]
keep_within_column: bool,
/// Relative to Spine: the settings describe a left-hand page, and on a right-hand page
/// the reference point, the object point and the X offset mirror horizontally, so an
/// object in the outside margin stays in the outside margin.
#[serde(default)]
spine_relative: bool,
},
}
+22 -7
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Insert Anchored Object…",
["Object", "Anchored Object"],
None,
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?} — moves the items into the text",
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)} — moves the items into the text",
has_doc,
insert
),
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Anchored Object Options…",
["Object", "Anchored Object"],
None,
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?}",
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)}",
has_doc,
options
),
@@ -80,11 +80,18 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
}
("custom", b) => {
use designcraft_doc::anchored::AnchorRelative as R;
let (xr0, yr0, xo0, yo0, op0, rp0, k0) = match b {
AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => {
(*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column)
}
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false),
let (xr0, yr0, xo0, yo0, op0, rp0, k0, sr0) = match b {
AnchorPosition::Custom {
x_relative,
y_relative,
x_offset,
y_offset,
object_point,
ref_point,
keep_within_column,
spine_relative,
} => (*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column, *spine_relative),
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false, false),
};
let rel = |k: &str, d: R| -> Result<R> {
match p.get(k) {
@@ -102,6 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
object_point: pt("objectPoint", op0),
ref_point: pt("refPoint", rp0),
keep_within_column: p.get("keepWithinColumn").and_then(Value::as_bool).unwrap_or(k0),
spine_relative: p.get("spineRelative").and_then(Value::as_bool).unwrap_or(sr0),
}
}
(o, _) => return Err(bad("anchored", format!("unknown position `{o}`"))),
@@ -316,5 +324,12 @@ mod custom_tests {
matches!(obj.position, designcraft_doc::AnchorPosition::Custom { x_offset, object_point: 2, .. } if x_offset == -6.0),
"IDML Anchored position"
);
// Relative to Spine keeps the other custom settings.
s.execute("anchored.options", &json!({"story": sid.0, "index": 0, "spineRelative": true})).unwrap();
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert!(matches!(
st.objects[0].position,
designcraft_doc::AnchorPosition::Custom { spine_relative: true, x_offset, object_point: 2, .. } if x_offset == -6.0
));
}
}
+3 -1
View File
@@ -2414,6 +2414,7 @@ impl<'a> Ex<'a> {
object_point,
ref_point,
keep_within_column,
spine_relative,
} => El::new("AnchoredObjectSetting")
.attr("AnchoredPosition", "Anchored")
.attr("AnchorPoint", names::anchor_out(*object_point))
@@ -2424,7 +2425,8 @@ impl<'a> Ex<'a> {
// IDML measures a left-aligned object's X offset leftwards.
.attr("AnchorXoffset", num(if *ref_point % 3 == 0 { 0.0 - *x_offset } else { *x_offset }))
.attr("AnchorYoffset", num(*y_offset))
.attr("PinPosition", bool_s(*keep_within_column)),
.attr("PinPosition", bool_s(*keep_within_column))
.attr("SpineRelative", bool_s(*spine_relative)),
};
e.push(setting);
out.push(Node::El(e));
+1
View File
@@ -2449,6 +2449,7 @@ impl<'r> Importer<'r> {
object_point: names::anchor_in(s("AnchorPoint")),
ref_point: row * 3 + col,
keep_within_column: s("PinPosition") == "true",
spine_relative: s("SpineRelative") == "true",
}
}
_ => designcraft_doc::AnchorPosition::Inline { y_offset: get("AnchorYoffset") },
+39
View File
@@ -1354,6 +1354,7 @@ fn anchored_objects_take_unset_settings_from_object_styles() {
object_point,
ref_point: 6,
keep_within_column,
spine_relative: false,
};
let positions: Vec<_> = s.objects.iter().map(|o| o.position.clone()).collect();
assert_eq!(
@@ -1366,6 +1367,44 @@ fn anchored_objects_take_unset_settings_from_object_styles() {
);
}
/// Relative to Spine comes from the object style chain like the other anchored settings, an
/// item's own setting wins, and export writes it back.
#[test]
fn anchored_spine_relative_comes_from_object_styles_and_round_trips() {
use designcraft_doc::AnchorPosition;
let styles = STYLES.replace(
r#"<ObjectStyle Self="ObjectStyle/$ID/[None]" Name="$ID/[None]"/>"#,
r#"<ObjectStyle Self="ObjectStyle/$ID/[None]" Name="$ID/[None]"/>
<ObjectStyle Self="ObjectStyle/Side" Name="Side"><AnchoredObjectSetting SpineRelative="true"/></ObjectStyle>
<ObjectStyle Self="ObjectStyle/Note" Name="Note"><Properties><BasedOn type="object">ObjectStyle/Side</BasedOn></Properties></ObjectStyle>"#,
);
let rect = |style: &str, setting: &str| {
format!(
r#"<Rectangle Self="r{style}" AppliedObjectStyle="ObjectStyle/{style}" ItemTransform="1 0 0 1 0 0">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 10"/><PathPointType Anchor="10 10"/><PathPointType Anchor="10 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
<AnchoredObjectSetting AnchoredPosition="Anchored" {setting}/>
</Rectangle>"#
)
};
let story = format!(
r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
<ParagraphStyleRange><CharacterStyleRange><Content>a</Content>{}{}{}</CharacterStyleRange></ParagraphStyleRange>
</Story></idPkg:Story>"#,
rect("Note", ""),
rect("Note", r#"SpineRelative="false""#),
rect("$ID/[None]", ""),
);
let spine = |d: &Document| -> Vec<bool> {
let s = d.stories.values().find(|s| !s.objects.is_empty()).unwrap();
s.objects.iter().map(|o| matches!(o.position, AnchorPosition::Custom { spine_relative: true, .. })).collect()
};
let d = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap();
assert_eq!(spine(&d), [true, false, false]);
assert_eq!(spine(&import_idml(&export_idml(&d)).unwrap()), [true, false, false]);
}
/// IDML measures a left-aligned anchored object's X offset leftwards; the document model and
/// composition measure every X offset rightwards.
#[test]