From 5a9fa2d16afe2cc4d5c9aad71aa4928e482d2ce9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:21:24 -0400 Subject: [PATCH] =?UTF-8?q?M5:=20anchored=20objects=20=E2=80=94=20inline?= =?UTF-8?q?=20and=20above=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doc: OBJECT_MARK with Story::objects (items normalized to the origin) kept in sync on edits, copy/paste slices and threading - compose: inline objects are wide glyphs sitting on the baseline (Y offset; auto leading grows); above-line objects push their line down; placements per frame follow vertical justification - render + PDF draw anchored items with their lines - engine: anchored.insert/options/release/list; pasting copied items into text anchors them; Options/Release act on the object at the insertion point (+ test) - Object › Anchored Object menu Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 60 +++++++ crates/compose/src/shape.rs | 35 ++++ crates/doc/src/anchored.rs | 101 ++++++++++++ crates/doc/src/edit.rs | 2 + crates/doc/src/lib.rs | 2 + crates/doc/src/slice.rs | 7 + crates/doc/src/story.rs | 6 +- crates/doc/src/xref.rs | 22 ++- crates/engine/src/cmd/anchored.rs | 255 ++++++++++++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 19 +++ crates/engine/src/cmd/mod.rs | 2 + crates/idml/src/import.rs | 1 + crates/pdf/src/export.rs | 13 ++ crates/render/src/text.rs | 10 ++ crates/ui-egui/src/menus.rs | 4 + 16 files changed, 537 insertions(+), 3 deletions(-) create mode 100644 crates/doc/src/anchored.rs create mode 100644 crates/engine/src/cmd/anchored.rs diff --git a/ROADMAP.md b/ROADMAP.md index 5573daf..bc0f022 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +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. - 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/compose/src/lib.rs b/crates/compose/src/lib.rs index b062422..a00f120 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -114,6 +114,22 @@ pub struct FrameText { pub tables: Vec, /// Footnotes placed at the bottom of this frame's columns. pub notes: Vec, + /// Anchored objects placed in this frame's lines. + pub objects: Vec, +} + +/// An anchored object placed in the text. +#[derive(Clone, Debug)] +pub struct PlacedObject { + /// Index in the story's [`Story::objects`]. + pub index: usize, + /// Top-left of the object in frame inner space. + pub origin: Point, + pub size: (f64, f64), + /// The line it sits on (or above). + pub line: usize, + x: f64, + text_ascent: f64, } /// A footnote composed at the bottom of a column. @@ -358,12 +374,25 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo vars: var_values, ..Default::default() }; + if !story.objects.is_empty() { + let before = story.text[..prange.start].matches(designcraft_doc::OBJECT_MARK).count(); + for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::OBJECT_MARK).enumerate() { + let Some(o) = story.objects.get(before + k) else { continue }; + let (w, h) = o.size(); + let (y_offset, space) = match &o.position { + designcraft_doc::AnchorPosition::Inline { y_offset } => (Some(*y_offset), 0.0), + designcraft_doc::AnchorPosition::AboveLine { space_before, space_after, .. } => (None, space_before + space_after), + }; + sub.objects.insert(prange.start + i, shape::ObjectSpec { index: before + k, w, h, y_offset, space }); + } + } if !story.xrefs.is_empty() { sub.xrefs = xref::texts_in(doc, story, prange.clone(), opts.xrefs.as_deref()); } if notes.active() { notes.number_refs(doc, prange.clone(), cur_frame.and_then(|f| f.page), &mut sub); } + let sub_objects = sub.objects.clone(); let mut table = StyleTable { styles: &mut styles_tab }; let mut sp = shape::shape_para(db, &doc.styles, story, pi, prange.clone(), &base_chars, pp.auto_leading, &sub, &mut table); match pp.list_type { @@ -564,6 +593,17 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo for k in line_notes { notes.place(doc, k, cur.fi, cur.col, col_w, f, opts); } + if !sub_objects.is_empty() { + let ft = &mut out.frames[cur.fi]; + let li = ft.lines.len() - 1; + let text_ascent = line_glyphs.iter().filter(|g| g.ch != designcraft_doc::OBJECT_MARK).map(|g| g.ascent).fold(0.0, f64::max); + let text_ascent = if text_ascent > 0.0 { text_ascent } else { base_chars.size * 0.75 }; + for g in ft.lines[li].glyphs.iter().filter(|g| g.len > 0) { + if let Some(o) = sub_objects.get(&g.byte) { + ft.objects.push(PlacedObject { index: o.index, origin: Point::ZERO, size: (o.w, o.h), line: li, x: g.x, text_ascent }); + } + } + } cur.last_baseline = Some(baseline); cur.last_descent = desc; cur.pending = 0.0; @@ -665,6 +705,25 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); t.shift(now - b); } + // Anchored objects follow their (finally placed) lines. + if !ft.objects.is_empty() { + for o in &mut ft.objects { + let Some(l) = ft.lines.get(o.line) else { continue }; + let Some(obj) = story.objects.get(o.index) else { continue }; + let (w, h) = o.size; + o.origin = match &obj.position { + designcraft_doc::AnchorPosition::Inline { y_offset } => Point::new(o.x, l.baseline - y_offset - h), + designcraft_doc::AnchorPosition::AboveLine { align, space_after, .. } => { + let x = match align { + designcraft_doc::anchored::AnchorAlign::Left => l.x0, + designcraft_doc::anchored::AnchorAlign::Center => (l.x0 + l.x1 - w) / 2.0, + designcraft_doc::anchored::AnchorAlign::Right => l.x1 - w, + }; + Point::new(x, l.baseline - o.text_ascent - space_after - h) + } + }; + } + } } // Frame ranges for empty frames after the text: start at the end of the text shown. let mut last_end = 0; @@ -742,6 +801,7 @@ impl Snapshot { f.lines.truncate(l); f.decos.truncate(d); f.tables.truncate(t); + f.objects.retain(|o| o.line < l); } *cur = self.cur.clone(); *list_counter = self.list_counter; diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index c4ad1e3..f4dd241 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -75,6 +75,20 @@ pub struct SubstCtx { pub note_style: Option, /// Cross-reference texts by story byte of the cross-reference mark. pub xrefs: HashMap, + /// Anchored objects by story byte of their mark. + pub objects: HashMap, +} + +/// Size and placement of an anchored object for line layout. +#[derive(Clone, Copy, Debug, Default)] +pub struct ObjectSpec { + pub index: usize, + pub w: f64, + pub h: f64, + /// Inline: raise above the baseline. Above line: None. + pub y_offset: Option, + /// Above line: space before + after. + pub space: f64, } pub(crate) struct StyleTable<'a> { @@ -227,6 +241,7 @@ fn shape_run( | designcraft_doc::XREF_MARK | designcraft_doc::ANCHOR_MARK | designcraft_doc::INDEX_MARK + | designcraft_doc::OBJECT_MARK ) || designcraft_doc::vars::var_index(c).is_some(); if special { flush(seg_start, i, &seg_face, out); @@ -251,6 +266,26 @@ fn shape_run( }; shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out); } + designcraft_doc::OBJECT_MARK => { + let mut g = control_glyph(&primary, p, auto_leading, style, i, c); + if let Some(o) = sub.objects.get(&i) { + let auto = matches!(p.leading, Leading::Auto); + match o.y_offset { + // Sits on the baseline like a (big) character. + Some(y) => { + g.adv = o.w; + g.ascent = g.ascent.max(o.h + y); + g.descent = g.descent.max(-y); + } + // Pushes its line down by its height and spacing. + None => g.ascent += o.h + o.space, + } + if auto || o.y_offset.is_none() { + g.leading = g.leading.max(g.ascent + g.descent); + } + } + out.push(g); + } designcraft_doc::XREF_MARK => match sub.xrefs.get(&i).filter(|t| !t.is_empty()) { Some(t) => shape_segment(db, text, i..i + c.len_utf8(), Some(t), p, &primary, auto_leading, style, out), None => { diff --git a/crates/doc/src/anchored.rs b/crates/doc/src/anchored.rs new file mode 100644 index 0000000..61a03d2 --- /dev/null +++ b/crates/doc/src/anchored.rs @@ -0,0 +1,101 @@ +//! Anchored objects (Object › Anchored Object): page items that flow with the text. +//! +//! An anchored object is an [`OBJECT_MARK`] character in a story; [`Story::objects`] holds one +//! [`AnchoredObject`] per mark in text order (like footnotes). The item's geometry is kept with +//! its top-left at the origin; composition places it on the line (inline) or on a line of its +//! own above the line it is anchored in (above line). + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::item::Item; +use crate::story::Story; + +/// An anchored object's position in the text. +pub const OBJECT_MARK: char = '\u{E00E}'; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AnchorAlign { + #[default] + Left, + Center, + Right, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum AnchorPosition { + /// Sits on the baseline like a character, raised by `y_offset`. + Inline { + #[serde(default)] + y_offset: f64, + }, + /// On its own line above the anchor's line. + AboveLine { + #[serde(default)] + align: AnchorAlign, + #[serde(default)] + space_before: f64, + #[serde(default)] + space_after: f64, + }, +} + +impl Default for AnchorPosition { + fn default() -> Self { + AnchorPosition::Inline { y_offset: 0.0 } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnchoredObject { + /// The item, with its bounds' top-left at the origin of its parent space. + pub item: Item, + #[serde(default)] + pub position: AnchorPosition, +} + +impl AnchoredObject { + /// Wrap `item`, moving it so its bounds start at the origin. + pub fn new(mut item: Item, position: AnchorPosition) -> Self { + let b = item.bounds(); + item.xf = designcraft_geom::Affine::translate((-b.x0, -b.y0)) * item.xf; + AnchoredObject { item, position } + } + + /// Width and height. + pub fn size(&self) -> (f64, f64) { + let b = self.item.bounds(); + (b.width(), b.height()) + } +} + +impl Default for AnchoredObject { + /// An empty placeholder (an object mark typed or pasted without its object). + fn default() -> Self { + let path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 0.0, 0.0)); + let item = Item::new(crate::ids::ItemId(0), crate::ids::LayerId(0), crate::item::Shape::Rectangle, path); + AnchoredObject { item, position: AnchorPosition::default() } + } +} + +impl Story { + /// Insert an anchored object at `pos`. + pub fn insert_object(&mut self, pos: usize, obj: AnchoredObject) { + let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len())); + self.insert(pos, &OBJECT_MARK.to_string()); + let k = self.text[..pos].matches(OBJECT_MARK).count(); + self.objects[k] = Arc::new(obj); + } + + /// The anchored object whose mark is at byte `pos`. + pub fn object_at(&self, pos: usize) -> Option<&AnchoredObject> { + if !self.text[pos.min(self.text.len())..].starts_with(OBJECT_MARK) { + return None; + } + self.objects.get(self.text[..pos].matches(OBJECT_MARK).count()).map(|o| &**o) + } +} diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 4b53c78..0f8914f 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -234,6 +234,7 @@ impl Document { st.anchors.extend(other.anchors.iter().cloned()); st.xrefs.extend(other.xrefs.iter().cloned()); st.index_refs.extend(other.index_refs.iter().cloned()); + st.objects.extend(other.objects.iter().cloned()); st.rev += 1; } let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); @@ -364,6 +365,7 @@ impl Document { st.anchors.clear(); st.xrefs.clear(); st.index_refs.clear(); + st.objects.clear(); st.fix_marks(); st.rev += 1; Ok(()) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 4fc067e..7282faf 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -8,6 +8,7 @@ //! x = 0 with their tops at y = 0. Items live in spread space via `Item::xf`. #![forbid(unsafe_code)] +pub mod anchored; pub mod attrs; pub mod build; mod edit; @@ -27,6 +28,7 @@ pub mod xref; use std::collections::BTreeMap; use std::sync::Arc; +pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK}; pub use attrs::*; pub use designcraft_color as color; pub use designcraft_geom as geom; diff --git a/crates/doc/src/slice.rs b/crates/doc/src/slice.rs index a22e749..b376bf0 100644 --- a/crates/doc/src/slice.rs +++ b/crates/doc/src/slice.rs @@ -35,6 +35,7 @@ impl Story { out.anchors = self.anchors[span(ANCHOR_MARK)].to_vec(); out.xrefs = self.xrefs[span(XREF_MARK)].to_vec(); out.index_refs = self.index_refs[span(INDEX_MARK)].to_vec(); + out.objects = self.objects[span(crate::anchored::OBJECT_MARK)].to_vec(); for p in &out.paras { if let Some(id) = p.table && let Some(t) = self.tables.get(&id) @@ -75,6 +76,12 @@ impl Story { // Payloads of footnotes, cross-references and index markers, in order. let first = |m: char| self.text[..pos].matches(m).count(); let (nf, nx, ni, na) = (first(FOOTNOTE_REF), first(XREF_MARK), first(INDEX_MARK), first(ANCHOR_MARK)); + let no = first(crate::anchored::OBJECT_MARK); + for (k, o) in src.objects.iter().enumerate() { + if let Some(slot) = self.objects.get_mut(no + k) { + *slot = o.clone(); + } + } for (k, n) in src.notes.iter().enumerate() { if let Some(slot) = self.notes.get_mut(nf + k) { let id = slot.id; diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index df2e57e..844b937 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -107,6 +107,9 @@ pub struct Story { /// Index page references, one per [`crate::index::INDEX_MARK`] in text order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub index_refs: Vec>, + /// Anchored objects, one per [`crate::anchored::OBJECT_MARK`] in text order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub objects: Vec>, } impl Story { @@ -123,6 +126,7 @@ impl Story { anchors: Vec::new(), xrefs: Vec::new(), index_refs: Vec::new(), + objects: Vec::new(), } } @@ -211,7 +215,7 @@ impl Story { let k = self.notes_before(pos); self.notes_inserted(k, refs); } - if text.contains([crate::xref::ANCHOR_MARK, crate::xref::XREF_MARK, crate::index::INDEX_MARK]) { + if text.contains([crate::xref::ANCHOR_MARK, crate::xref::XREF_MARK, crate::index::INDEX_MARK, crate::anchored::OBJECT_MARK]) { self.marks_inserted(pos, text); } // Paragraph formats: each new '\n' splits the current paragraph; the new paragraphs copy it. diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index 6e87dd3..bdaca7e 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -208,6 +208,13 @@ impl Story { self.index_refs.insert(k + i, Arc::new(crate::index::IndexRef::default())); } } + let n = text.matches(crate::anchored::OBJECT_MARK).count(); + if n > 0 { + let k = self.marks_before(crate::anchored::OBJECT_MARK, pos); + for i in 0..n { + self.objects.insert(k + i, Arc::new(crate::anchored::AnchoredObject::default())); + } + } } /// Insert an index page reference at `pos`. @@ -220,7 +227,7 @@ impl Story { /// Keep anchor/xref lists in step with a deletion of `a..b` (before it happens). pub(crate) fn marks_deleted(&mut self, a: usize, b: usize) { - for mark in [ANCHOR_MARK, XREF_MARK, crate::index::INDEX_MARK] { + for mark in [ANCHOR_MARK, XREF_MARK, crate::index::INDEX_MARK, crate::anchored::OBJECT_MARK] { let n = self.text[a..b].matches(mark).count(); if n == 0 { continue; @@ -230,8 +237,10 @@ impl Story { self.anchors.drain(k..(k + n).min(self.anchors.len())); } else if mark == XREF_MARK { self.xrefs.drain(k..(k + n).min(self.xrefs.len())); - } else { + } else if mark == crate::index::INDEX_MARK { self.index_refs.drain(k..(k + n).min(self.index_refs.len())); + } else { + self.objects.drain(k..(k + n).min(self.objects.len())); } } } @@ -256,6 +265,11 @@ impl Story { while self.index_refs.len() < n { self.index_refs.push(Arc::new(crate::index::IndexRef::default())); } + let n = self.text.matches(crate::anchored::OBJECT_MARK).count(); + self.objects.truncate(n); + while self.objects.len() < n { + self.objects.push(Arc::new(crate::anchored::AnchoredObject::default())); + } } pub(crate) fn check_marks(&self) -> Result<(), String> { @@ -263,6 +277,10 @@ impl Story { if a != self.anchors.len() || x != self.xrefs.len() { return Err(format!("story {}: {a} anchor / {x} xref marks for {} anchors / {} xrefs", self.id.0, self.anchors.len(), self.xrefs.len())); } + let o = self.text.matches(crate::anchored::OBJECT_MARK).count(); + if o != self.objects.len() { + return Err(format!("story {}: {o} object marks for {} anchored objects", self.id.0, self.objects.len())); + } let i = self.text.matches(crate::index::INDEX_MARK).count(); if i != self.index_refs.len() { return Err(format!("story {}: {i} index marks for {} references", self.id.0, self.index_refs.len())); diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs new file mode 100644 index 0000000..fb3a04c --- /dev/null +++ b/crates/engine/src/cmd/anchored.rs @@ -0,0 +1,255 @@ +//! Object › Anchored Object: items that flow with the text (inline or above the line). + +use std::sync::Arc; + +use designcraft_doc::{ + AnchorPosition, AnchoredObject, Content, Document, Item, ItemId, OBJECT_MARK, SpreadRef, StoryId, TextSel, anchored::AnchorAlign, +}; +use designcraft_geom::Affine; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "anchored.insert", + "Insert Anchored Object…", + ["Object", "Anchored Object"], + None, + "{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?} — moves the items into the text", + has_doc, + insert + ), + cmd!( + "anchored.options", + "Anchored Object Options…", + ["Object", "Anchored Object"], + None, + "{story?, index? (default: the object at the insertion point), position?: inline|aboveLine, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?}", + has_doc, + options + ), + cmd!( + "anchored.release", + "Release", + ["Object", "Anchored Object"], + None, + "{story?, index? (default: the object at the insertion point)} — puts the object back on the page where it is now and removes it from the text", + has_doc, + release + ), + cmd!(query "anchored.list", "Anchored Objects", [], None, "{story?} → [{story, index, pos, position, size}]", has_doc, |s, p| { + let d = &s.doc()?.doc; + let only = p.get("story").and_then(Value::as_u64).map(StoryId); + let mut out = Vec::new(); + for st in d.stories.values().filter(|st| only.is_none_or(|o| o == st.id)) { + for (k, ((pos, _), o)) in st.text.match_indices(OBJECT_MARK).zip(&st.objects).enumerate() { + out.push(json!({"story": st.id.0, "index": k, "pos": pos, "position": o.position, "size": o.size()})); + } + } + Ok(Value::Array(out)) + }), + ] +} + +fn position_param(p: &Value, base: &AnchorPosition) -> Result { + let f = |k: &str, d: f64| p.get(k).and_then(Value::as_f64).unwrap_or(d); + let kind = str_param(p, "position").unwrap_or(match base { + AnchorPosition::Inline { .. } => "inline", + AnchorPosition::AboveLine { .. } => "aboveLine", + }); + Ok(match (kind, base) { + ("inline", AnchorPosition::Inline { y_offset }) => AnchorPosition::Inline { y_offset: f("yOffset", *y_offset) }, + ("inline", _) => AnchorPosition::Inline { y_offset: f("yOffset", 0.0) }, + ("aboveLine", b) => { + let (a0, sb0, sa0) = match b { + AnchorPosition::AboveLine { align, space_before, space_after } => (*align, *space_before, *space_after), + _ => (AnchorAlign::Left, 0.0, 0.0), + }; + let align = match str_param(p, "align") { + Some("center") => AnchorAlign::Center, + Some("right") => AnchorAlign::Right, + Some("left") => AnchorAlign::Left, + Some(o) => return Err(bad("anchored", format!("unknown align `{o}`"))), + None => a0, + }; + AnchorPosition::AboveLine { align, space_before: f("spaceBefore", sb0), space_after: f("spaceAfter", sa0) } + } + (o, _) => return Err(bad("anchored", format!("unknown position `{o}`"))), + }) +} + +/// Text frames can't be anchored yet (their stories need frames on a spread). +fn check_item(it: &Item) -> Result<()> { + let mut ok = true; + it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_))); + if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) } +} + +/// Anchor copies of `items` (in spread coordinates) at `pos` of `sid`; returns the end position. +pub(crate) fn anchor_items(d: &mut Document, sid: StoryId, pos: usize, items: Vec, position: &AnchorPosition) -> Result { + let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let mut at = pos.min(st.len()); + for it in items { + st.insert_object(at, AnchoredObject::new(it, position.clone())); + at += OBJECT_MARK.len_utf8(); + } + Ok(at) +} + +fn insert(s: &mut Session, p: &Value) -> Result { + let ids = super::ids_param(p, "ids").ok_or_else(|| bad("anchored.insert", "missing ids"))?; + let position = position_param(p, &AnchorPosition::default())?; + let st = s.doc()?; + let (sid, pos) = match (p.get("story").and_then(Value::as_u64), p.get("pos").and_then(Value::as_u64)) { + (Some(sid), pos) => (StoryId(sid), pos.map(|v| v as usize).unwrap_or(usize::MAX)), + _ => { + let t = st + .selection + .text + .filter(|t| t.cell.is_none()) + .ok_or_else(|| bad("anchored.insert", "give `story` or place the insertion point in story text"))?; + (t.story, t.range().start) + } + }; + let mut items = Vec::new(); + for id in &ids { + let loc = st.doc.find(*id).ok_or_else(|| bad("anchored.insert", format!("no item {}", id.0)))?; + let it = st.doc.item(*id).ok_or_else(|| bad("anchored.insert", format!("no item {}", id.0)))?; + check_item(it)?; + // Into spread coordinates (items inside groups carry their parents' transforms). + let mut it = it.clone(); + it.xf = st.doc.parent_xf(&loc) * it.xf; + items.push(it); + } + s.edit(|d, sel| { + for id in &ids { + d.remove_item(*id)?; + } + let end = anchor_items(d, sid, pos, items.clone(), &position)?; + *sel = designcraft_doc::Selection::text(TextSel { story: sid, anchor: end, focus: end, frame: None, cell: None }); + Ok(json!({"story": sid.0, "pos": end})) + }) +} + +/// `{story, index}`, or the anchored object at (or just before) the text insertion point. +fn object_ref(s: &Session, p: &Value, cmd: &str) -> Result<(StoryId, usize)> { + if let (Some(sid), Some(k)) = (p.get("story").and_then(Value::as_u64), p.get("index").and_then(Value::as_u64)) { + return Ok((StoryId(sid), k as usize)); + } + let st = s.doc()?; + let t = st + .selection + .text + .filter(|t| t.cell.is_none()) + .ok_or_else(|| bad(cmd, "give `story` and `index`, or put the insertion point next to an anchored object"))?; + let story = st.doc.story(t.story).ok_or_else(|| bad(cmd, "no such story"))?; + let r = t.range(); + let at = [r.start, r.start.saturating_sub(OBJECT_MARK.len_utf8())] + .into_iter() + .chain(story.text[r.clone()].find(OBJECT_MARK).map(|i| r.start + i)) + .find(|&i| story.text.get(i..).is_some_and(|x| x.starts_with(OBJECT_MARK))) + .ok_or_else(|| bad(cmd, "no anchored object at the insertion point"))?; + Ok((t.story, story.text[..at].matches(OBJECT_MARK).count())) +} + +fn options(s: &mut Session, p: &Value) -> Result { + let (sid, k) = object_ref(s, p, "anchored.options")?; + let cur = s.doc()?.doc.story(sid).and_then(|st| st.objects.get(k).cloned()).ok_or_else(|| bad("anchored.options", "no such anchored object"))?; + let position = position_param(p, &cur.position)?; + s.edit(|d, _| { + let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let o = st.objects.get_mut(k).ok_or_else(|| bad("anchored.options", "no such anchored object"))?; + Arc::make_mut(o).position = position.clone(); + st.rev += 1; + Ok(json!({"position": position})) + }) +} + +fn release(s: &mut Session, p: &Value) -> Result { + let (sid, k) = object_ref(s, p, "anchored.release")?; + let d0 = s.doc()?.doc.clone(); + let st = d0.story(sid).ok_or_else(|| bad("anchored.release", "no such story"))?; + let obj = st.objects.get(k).cloned().ok_or_else(|| bad("anchored.release", "no such anchored object"))?; + let pos = st.text.match_indices(OBJECT_MARK).nth(k).map(|(i, _)| i).ok_or_else(|| bad("anchored.release", "no mark"))?; + // Where it shows now: its frame's transform and its placement. + let cs = s.cache.get(&d0, sid, None); + let placed = cs.frames.iter().find_map(|ft| ft.objects.iter().find(|o| o.index == k).map(|o| (ft.frame, o.origin))); + let (spread, xf) = match placed { + Some((fid, origin)) => { + let loc = d0.find(fid).ok_or_else(|| bad("anchored.release", "frame not found"))?; + let fxf = d0.parent_xf(&loc) * d0.item(fid).map_or(Affine::IDENTITY, |i| i.xf); + (loc.spread, fxf * Affine::translate(origin.to_vec2())) + } + None => (SpreadRef::Doc(0), Affine::IDENTITY), + }; + s.edit(|d, sel| { + let mut it = obj.item.clone(); + it.id = ItemId(d.alloc()); + it.xf = xf * it.xf; + if d.layer(it.layer).is_none() { + it.layer = d.default_layer(); + } + let id = d.insert_item(spread, it, None)?; + let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + st.delete(pos..pos + OBJECT_MARK.len_utf8()); + *sel = designcraft_doc::Selection::items(vec![id]); + Ok(json!({"id": id.0})) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inline_object_flows_with_text_and_releases() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."})).unwrap(); + let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let r = s.execute("frame.create", &json!({"rect": [450, 450, 474, 490]})).unwrap(); + let box_id = r["id"].as_u64().unwrap(); + s.execute("anchored.insert", &json!({"ids": [box_id], "story": sid.0, "pos": 7})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ItemId(box_id)).is_none(), "moved into the text"); + let st = d.story(sid).unwrap(); + assert_eq!(st.objects.len(), 1); + assert_eq!(st.objects[0].size(), (24.0, 40.0)); + let cs = s.cache.get(&d, sid, None); + let ft = &cs.frames[0]; + assert_eq!(ft.objects.len(), 1); + let o = &ft.objects[0]; + let line = &ft.lines[o.line]; + // Inline: bottom on the baseline, the line grows to fit it. + assert!((o.origin.y + 40.0 - line.baseline).abs() < 1e-6); + assert!(line.ascent >= 40.0); + // The text after it moved right by its width. + let after = line.glyphs.iter().find(|g| g.byte == 7 + OBJECT_MARK.len_utf8()).unwrap(); + assert!(after.x >= o.origin.x + 24.0 - 1e-6); + // Typing before it moves it along. + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("text.insert", &json!({"text": "Words "})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let x2 = s.cache.get(&d, sid, None).frames[0].objects[0].origin.x; + assert!(x2 > o.origin.x); + // Above line: the object sits over its line, which moves down. + s.execute("anchored.options", &json!({"story": sid.0, "index": 0, "position": "aboveLine", "align": "center", "spaceAfter": 4.0})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, None); + let o = &cs.frames[0].objects[0]; + let line = &cs.frames[0].lines[o.line]; + assert!(o.origin.y + 40.0 + 4.0 <= line.baseline - 5.0, "{o:?} {}", line.baseline); + // Release: back on the page where it was shown. + s.execute("anchored.release", &json!({"story": sid.0, "index": 0})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.story(sid).unwrap().objects.is_empty()); + let id = s.doc().unwrap().selection.items[0]; + let b = d.item(id).unwrap().bounds(); + // (The frame's inner space is spread space here.) + assert!((b.width() - 24.0).abs() < 1e-6 && (b.x0 - o.origin.x).abs() < 1e-6 && (b.y0 - o.origin.y).abs() < 1e-6, "{b:?}"); + d.check().unwrap(); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9c017e4..71770d3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -55,6 +55,24 @@ fn copy_text(s: &mut Session) -> Result> { fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result { let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?; let clip = s.text_clipboard.clone(); + // Items copied last (no text copied since) paste into the text as anchored objects. + if formatted + && clip.is_none() + && t.cell.is_none() + && let Some(items) = s.clipboard.clone() + { + let list: Vec = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default(); + if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) { + return s.edit(|d, sel| { + let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let r = t.range(); + st.delete(r.start.min(st.len())..r.end.min(st.len())); + let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?; + sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t }); + Ok(json!({"pos": end, "anchored": list.len()})) + }); + } + } let use_clip = formatted && clip.as_ref().is_some_and(|(_, p)| text.as_ref().is_none_or(|x| x.replace("\r\n", "\n") == *p)); // Footnotes, cross-references and tables only paste into story text, not cells or footnotes. if use_clip && let Some((slice, _)) = clip { @@ -184,6 +202,7 @@ pub fn specs() -> Vec { return Ok(json!({"text": text})); } s.clipboard = Some(Arc::new(clip_doc(s)?)); + s.text_clipboard = None; ok() }), cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection_or_text, |s, p| { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d119565..1225718 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,5 +1,6 @@ //! The command registry. Ids follow InDesign's menu structure. +mod anchored; mod datamerge; mod edit; mod export; @@ -128,6 +129,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(notes::specs()); v.extend(xref::specs()); v.extend(index::specs()); + v.extend(anchored::specs()); v.extend(spelling::specs()); v.extend(datamerge::specs()); v.extend(interactive::specs()); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 7fb7d32..3bec552 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1026,6 +1026,7 @@ impl<'r> Importer<'r> { anchors: anchor_list, xrefs: xref_list, index_refs: index_refs.into_iter().map(Arc::new).collect(), + objects: vec![], }; st.fix_notes(); st.fix_marks(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 8fd563e..1a85595 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -485,6 +485,19 @@ impl Exporter<'_> { for _ in 0..pushes { s.pop(); } + // Anchored objects in the frame's text. + if let Content::Text(tfr) = &it.content + && let Some(st) = doc.story(tfr.story).filter(|st| !st.objects.is_empty()) + { + let cs = self.cache.get(doc, tfr.story, page_name); + if let Some(ft) = cs.frame(it.id) { + for o in &ft.objects { + if let Some(obj) = st.objects.get(o.index) { + self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page_name); + } + } + } + } } /// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown. diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 9886875..d361099 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -268,6 +268,16 @@ impl Renderer { self.draw_text(ctx, f, &n.text, nft, xf * Affine::translate(n.origin.to_vec2())); } } + // Anchored objects ride on their lines. + if !ft.objects.is_empty() + && let Some(st) = doc.story(cs.story) + { + for o in &ft.objects { + if let Some(obj) = st.objects.get(o.index) { + self.draw_item(ctx, f, &obj.item, xf * Affine::translate(o.origin.to_vec2()), None); + } + } + } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4abcf04..158642c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -279,6 +279,10 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.outerGlow", "cmd:object.feather", "<", + ">Anchored Object", + "cmd:anchored.options", + "cmd:anchored.release", + "<", ">Fitting", "cmd:object.fit|Fill Frame Proportionally|{\"mode\":\"fillProportionally\"}", "cmd:object.fit|Fit Content Proportionally|{\"mode\":\"fitProportionally\"}",