M5: anchored objects — inline and above line

- 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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 23:21:24 -04:00
parent 781c57ac5a
commit 5a9fa2d16a
16 changed files with 537 additions and 3 deletions

View File

@ -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.

View File

@ -114,6 +114,22 @@ pub struct FrameText {
pub tables: Vec<TableFrag>,
/// Footnotes placed at the bottom of this frame's columns.
pub notes: Vec<PlacedNote>,
/// Anchored objects placed in this frame's lines.
pub objects: Vec<PlacedObject>,
}
/// 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;

View File

@ -75,6 +75,20 @@ pub struct SubstCtx {
pub note_style: Option<String>,
/// Cross-reference texts by story byte of the cross-reference mark.
pub xrefs: HashMap<usize, String>,
/// Anchored objects by story byte of their mark.
pub objects: HashMap<usize, ObjectSpec>,
}
/// 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<f64>,
/// 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 => {

101
crates/doc/src/anchored.rs Normal file
View File

@ -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)
}
}

View File

@ -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(())

View File

@ -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;

View File

@ -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;

View File

@ -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<Arc<crate::index::IndexRef>>,
/// Anchored objects, one per [`crate::anchored::OBJECT_MARK`] in text order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub objects: Vec<Arc<crate::anchored::AnchoredObject>>,
}
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.

View File

@ -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()));

View File

@ -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<CommandSpec> {
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<AnchorPosition> {
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<Item>, position: &AnchorPosition) -> Result<usize> {
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<Value> {
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<Value> {
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<Value> {
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();
}
}

View File

@ -55,6 +55,24 @@ fn copy_text(s: &mut Session) -> Result<Option<String>> {
fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<Value> {
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<designcraft_doc::Item> = 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<CommandSpec> {
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| {

View File

@ -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());

View File

@ -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();

View File

@ -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.

View File

@ -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);
}
}
}
}
}

View File

@ -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\"}",