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:
parent
781c57ac5a
commit
5a9fa2d16a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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\"}",
|
||||
|
|
|
|||
Loading…
Reference in New Issue