diff --git a/ROADMAP.md b/ROADMAP.md index bc0f022..d849ae8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index c498593..f42e969 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -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); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 3bec552..e83a0fe 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -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 = 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, xrefs: Vec<(String, String)>, + objects: Vec, } impl StoryBuilder { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 22ffadc..5627517 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -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 }); +}