M6: anchored objects in IDML (inline / above line), verified in InDesign 2026

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 23:25:20 -04:00
parent 5a9fa2d16a
commit 252ae0df32
4 changed files with 88 additions and 14 deletions

View File

@ -21,7 +21,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands.
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.
- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered and exported to PDF; copy/paste and threading carry them; `anchored.*` commands.
- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands.
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.

View File

@ -1476,6 +1476,34 @@ impl<'a> Ex<'a> {
.attr("DestinationUniqueKey", a.id & 0xFFFF_FFFF),
));
}
} else if ch == designcraft_doc::OBJECT_MARK {
// An anchored object: the item inside the range with its anchoring settings.
flush_text(&mut cur, &mut pending);
flush_content(&mut pending, &mut out);
let k = s.text[..seg_start + ci].matches(designcraft_doc::OBJECT_MARK).count();
if let Some(o) = s.objects.get(k) {
let mut e = self.item_el(&o.item, o.item.xf);
use designcraft_doc::anchored::AnchorAlign as A;
let setting = match &o.position {
designcraft_doc::AnchorPosition::Inline { y_offset } => {
El::new("AnchoredObjectSetting").attr("AnchoredPosition", "InlinePosition").attr("AnchorYoffset", num(*y_offset))
}
designcraft_doc::AnchorPosition::AboveLine { align, space_before, space_after } => El::new("AnchoredObjectSetting")
.attr("AnchoredPosition", "AboveLine")
.attr(
"HorizontalAlignment",
match align {
A::Left => "LeftAlign",
A::Center => "CenterAlign",
A::Right => "RightAlign",
},
)
.attr("AnchorSpaceAbove", num(*space_before))
.attr("AnchorYoffset", num(*space_after)),
};
e.push(setting);
out.push(Node::El(e));
}
} else if ch == designcraft_doc::INDEX_MARK {
flush_text(&mut cur, &mut pending);
flush_content(&mut pending, &mut out);

View File

@ -987,9 +987,10 @@ impl<'r> Importer<'r> {
anchors: Vec::new(),
xrefs: Vec::new(),
index_refs: Vec::new(),
objects: Vec::new(),
};
self.walk_story(e, &mut b, &ParaFormat::default(), &CharAttrs::default(), &CharFormat::default(), None);
let StoryBuilder { text, mut paras, runs, last, tables: tbls, notes, anchors, xrefs, index_refs, .. } = b;
let StoryBuilder { text, mut paras, runs, last, tables: tbls, notes, anchors, xrefs, index_refs, objects, .. } = b;
let mut chars: Vec<CharRun> = runs.into_iter().filter(|r| r.len > 0).collect();
if chars.is_empty() {
chars.push(CharRun { len: 0, format: last });
@ -1026,7 +1027,7 @@ impl<'r> Importer<'r> {
anchors: anchor_list,
xrefs: xref_list,
index_refs: index_refs.into_iter().map(Arc::new).collect(),
objects: vec![],
objects: objects.into_iter().map(Arc::new).collect(),
};
st.fix_notes();
st.fix_marks();
@ -1250,17 +1251,30 @@ impl<'r> Importer<'r> {
b.push(&designcraft_doc::FOOTNOTE_REF.to_string(), &rcf);
}
// Not supported yet: skip their content entirely.
"Properties"
| "Note"
| "Rectangle"
| "Oval"
| "Polygon"
| "GraphicLine"
| "TextFrame"
| "Group"
| "StoryPreference"
| "InCopyExportOption"
| "TextVariableInstance" => {}
"Rectangle" | "Oval" | "Polygon" | "GraphicLine" | "Group" => {
// An anchored object (text frames inside stories aren't supported yet).
let has_text = c.find_all("TextFrame").next().is_some();
if !has_text && let Some(item) = self.item(c, designcraft_geom::Affine::IDENTITY) {
use designcraft_doc::anchored::AnchorAlign as A;
let set = c.find("AnchoredObjectSetting");
let get = |k: &str| set.and_then(|s| s.num(k)).unwrap_or(0.0);
let position = match set.and_then(|s| s.get("AnchoredPosition")) {
Some("AboveLine") => designcraft_doc::AnchorPosition::AboveLine {
align: match set.and_then(|s| s.get("HorizontalAlignment")) {
Some("CenterAlign") => A::Center,
Some("RightAlign") => A::Right,
_ => A::Left,
},
space_before: get("AnchorSpaceAbove"),
space_after: get("AnchorYoffset"),
},
_ => designcraft_doc::AnchorPosition::Inline { y_offset: get("AnchorYoffset") },
};
b.objects.push(designcraft_doc::AnchoredObject::new(item, position));
b.push(&designcraft_doc::OBJECT_MARK.to_string(), cf);
}
}
"Properties" | "Note" | "TextFrame" | "StoryPreference" | "InCopyExportOption" | "TextVariableInstance" => {}
_ => self.walk_story(c, b, pf, pchars, cf, brk),
},
Node::Pi(t, v) if t == "ACE" => {
@ -1751,6 +1765,7 @@ struct StoryBuilder {
anchors: Vec<(String, String)>,
index_refs: Vec<designcraft_doc::IndexRef>,
xrefs: Vec<(String, String)>,
objects: Vec<designcraft_doc::AnchoredObject>,
}
impl StoryBuilder {

View File

@ -490,3 +490,34 @@ fn round_trips_index_references() {
assert_eq!(s.text.replace(designcraft_doc::INDEX_MARK, ""), "Kerning adjusts pairs.\nMore.");
assert!(s.text.starts_with(&format!("{0}{0}Ke{0}rning", designcraft_doc::INDEX_MARK)), "{:?}", s.text);
}
#[test]
fn round_trips_anchored_objects() {
use designcraft_doc::{AnchorPosition, AnchoredObject, anchored::AnchorAlign};
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) =
d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Inline here.\nAbove.", ParaFormat::default()).unwrap();
let mut a =
designcraft_doc::Item::new(designcraft_doc::ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 16.0)));
a.fill = Fill::swatch(designcraft_color::swatch::BLACK);
let b = designcraft_doc::Item::new(designcraft_doc::ItemId(d.alloc()), lid, Shape::Oval, shapes::ellipse(Rect::new(0.0, 0.0, 60.0, 30.0)));
{
let s = d.story_mut(sid).unwrap();
s.insert_object(7, AnchoredObject::new(a, AnchorPosition::Inline { y_offset: 2.0 }));
let at = s.text.find("Above").unwrap();
s.insert_object(at, AnchoredObject::new(b, AnchorPosition::AboveLine { align: AnchorAlign::Center, space_before: 3.0, space_after: 4.0 }));
}
d.check().unwrap();
let back = import_idml(&export_idml(&d)).unwrap();
back.check().unwrap();
let s = back.stories.values().find(|s| !s.objects.is_empty()).unwrap();
assert_eq!(s.text, d.story(sid).unwrap().text);
assert_eq!(s.objects.len(), 2);
let (w, h) = s.objects[0].size();
assert!((w - 20.0).abs() < 1e-6 && (h - 16.0).abs() < 1e-6);
assert_eq!(s.objects[0].position, AnchorPosition::Inline { y_offset: 2.0 });
assert_eq!(s.objects[0].item.fill.swatch, designcraft_color::swatch::BLACK);
assert_eq!(s.objects[1].item.shape, Shape::Oval);
assert_eq!(s.objects[1].position, AnchorPosition::AboveLine { align: AnchorAlign::Center, space_before: 3.0, space_after: 4.0 });
}