T: custom anchored object positions (+ IDML)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 00:55:34 -04:00
co-authored by Claude Opus 5.5
parent 615b033f47
commit 2fefab232c
11 changed files with 217 additions and 10 deletions
+1
View File
@@ -65,6 +65,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label).
- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name.
- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting.
- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
+45 -4
View File
@@ -231,6 +231,8 @@ pub struct FrameSpec {
pub grid: Option<(f64, f64)>,
/// The frame is on a left page (for towards/away-from-spine alignment).
pub left_page: bool,
/// The page and its margins in the frame's inner space (custom anchored objects).
pub page_rect: Option<(Rect, Rect)>,
}
impl FrameSpec {
@@ -307,6 +309,14 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
}
_ => (None, None, false),
};
// The page under the frame and its margins, in inner space (bounding boxes).
let page_rect = match (loc.spread, spread) {
(_, Some(sp)) => sp
.page_at_x(item.bounds().center().x)
.and_then(|pi| sp.pages.get(pi))
.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))),
_ => None,
};
let g = &doc.settings.baseline_grid;
let (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, g.start));
// Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only).
@@ -342,6 +352,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
page,
grid,
left_page,
page_rect,
});
}
out
@@ -471,11 +482,12 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
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),
let (y_offset, space, custom) = match &o.position {
designcraft_doc::AnchorPosition::Inline { y_offset } => (Some(*y_offset), 0.0, false),
designcraft_doc::AnchorPosition::AboveLine { space_before, space_after, .. } => (None, space_before + space_after, false),
designcraft_doc::AnchorPosition::Custom { .. } => (Some(0.0), 0.0, true),
};
sub.objects.insert(prange.start + i, shape::ObjectSpec { index: before + k, w, h, y_offset, space });
sub.objects.insert(prange.start + i, shape::ObjectSpec { index: before + k, w, h, y_offset, space, custom });
}
}
if !story.xrefs.is_empty() {
@@ -880,6 +892,35 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
};
Point::new(x, l.baseline - o.text_ascent - space_after - h)
}
designcraft_doc::AnchorPosition::Custom {
x_relative,
y_relative,
x_offset,
y_offset,
object_point,
ref_point,
keep_within_column,
} => {
use designcraft_doc::anchored::AnchorRelative as R;
let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area);
let area = |r: R| match r {
R::Anchor => None,
R::TextFrame => Some(f.area),
R::ColumnEdge => Some(col),
R::PageMargin => Some(f.page_rect.map_or(f.area, |p| p.1)),
R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)),
};
let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]);
let (rfx, rfy) = frac(*ref_point);
let (ofx, ofy) = frac(*object_point);
let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx);
let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy);
let mut y = ry + y_offset - h * ofy;
if *keep_within_column {
y = y.clamp(col.y0, (col.y1 - h).max(col.y0));
}
Point::new(rx + x_offset - w * ofx, y)
}
};
}
}
+1
View File
@@ -104,6 +104,7 @@ impl Notes {
page: f.page,
grid: None,
left_page: f.left_page,
page_rect: None,
};
let nopts = ComposeOptions {
page_name: opts.page_name.clone(),
+3 -1
View File
@@ -94,6 +94,8 @@ pub struct ObjectSpec {
pub y_offset: Option<f64>,
/// Above line: space before + after.
pub space: f64,
/// Custom position: takes no space in the text.
pub custom: bool,
}
pub(crate) struct StyleTable<'a> {
@@ -354,7 +356,7 @@ fn shape_run(
}
designcraft_doc::OBJECT_MARK => {
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
if let Some(o) = sub.objects.get(&i) {
if let Some(o) = sub.objects.get(&i).filter(|o| !o.custom) {
let auto = matches!(p.leading, Leading::Auto);
match o.y_offset {
// Sits on the baseline like a (big) character.
+1
View File
@@ -96,6 +96,7 @@ fn compose_cell(doc: &Document, story: &Story, w: f64, opts: &ComposeOptions, le
page: opts.page,
grid: None,
left_page,
page_rect: None,
};
let cs = crate::compose(doc, story, std::slice::from_ref(&spec), opts);
let content_h = cs.frames.first().map(|f| if f.lines.is_empty() { 0.0 } else { f.content_height }).unwrap_or(0.0);
+33
View File
@@ -41,6 +41,39 @@ pub enum AnchorPosition {
#[serde(default)]
space_after: f64,
},
/// Custom: placed relative to the frame, column, page margins or page edge (or the anchor
/// marker / line), taking no space in the text. `object_point` is the point on the object and
/// `ref_point` the point on the reference area that meet (0–8, row-major), plus the offsets.
Custom {
#[serde(default)]
x_relative: AnchorRelative,
#[serde(default)]
y_relative: AnchorRelative,
#[serde(default)]
x_offset: f64,
#[serde(default)]
y_offset: f64,
#[serde(default)]
object_point: u8,
#[serde(default)]
ref_point: u8,
/// Keep Within Top/Bottom Column Boundaries.
#[serde(default)]
keep_within_column: bool,
},
}
/// What a custom anchored object is positioned against.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AnchorRelative {
/// The anchor marker (x) / the anchor's line baseline (y).
#[default]
Anchor,
TextFrame,
ColumnEdge,
PageMargin,
PageEdge,
}
impl Default for AnchorPosition {
+67 -2
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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",
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative?/yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint?/refPoint?: 0..8, keepWithinColumn?} — moves the items into the text",
has_doc,
insert
),
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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?}",
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative?/yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint?/refPoint?: 0..8, keepWithinColumn?}",
has_doc,
options
),
@@ -59,6 +59,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
let kind = str_param(p, "position").unwrap_or(match base {
AnchorPosition::Inline { .. } => "inline",
AnchorPosition::AboveLine { .. } => "aboveLine",
AnchorPosition::Custom { .. } => "custom",
});
Ok(match (kind, base) {
("inline", AnchorPosition::Inline { y_offset }) => AnchorPosition::Inline { y_offset: f("yOffset", *y_offset) },
@@ -77,6 +78,32 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
};
AnchorPosition::AboveLine { align, space_before: f("spaceBefore", sb0), space_after: f("spaceAfter", sa0) }
}
("custom", b) => {
use designcraft_doc::anchored::AnchorRelative as R;
let (xr0, yr0, xo0, yo0, op0, rp0, k0) = match b {
AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => {
(*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column)
}
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false),
};
let rel = |k: &str, d: R| -> Result<R> {
match p.get(k) {
Some(v) => serde_json::from_value(v.clone())
.map_err(|_| bad("anchored", format!("`{k}`: anchor|textFrame|columnEdge|pageMargin|pageEdge"))),
None => Ok(d),
}
};
let pt = |k: &str, d: u8| p.get(k).and_then(Value::as_u64).map_or(d, |v| v.min(8) as u8);
AnchorPosition::Custom {
x_relative: rel("xRelative", xr0)?,
y_relative: rel("yRelative", yr0)?,
x_offset: f("xOffset", xo0),
y_offset: f("yOffset", yo0),
object_point: pt("objectPoint", op0),
ref_point: pt("refPoint", rp0),
keep_within_column: p.get("keepWithinColumn").and_then(Value::as_bool).unwrap_or(k0),
}
}
(o, _) => return Err(bad("anchored", format!("unknown position `{o}`"))),
})
}
@@ -253,3 +280,41 @@ mod tests {
d.check().unwrap();
}
}
#[cfg(test)]
mod custom_tests {
use serde_json::json;
use crate::Session;
#[test]
fn custom_anchored_object_sits_in_the_page_margin_and_takes_no_space() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Side note here"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let end_before = s.cache.get(&s.doc().unwrap().doc, sid, None).frames[0].lines[0].end_x;
let box_id = s.execute("frame.create", &json!({"rect": [0, 0, 30, 20], "content": "unassigned"})).unwrap()["id"].clone();
// To the left of the page margin, top of the box on the anchor's baseline.
s.execute(
"anchored.insert",
&json!({"ids": [box_id], "story": sid.0, "pos": 4, "position": "custom", "xRelative": "pageMargin", "yRelative": "anchor", "refPoint": 0, "objectPoint": 2, "xOffset": -6}),
)
.unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
let ft = &cs.frames[0];
assert!((ft.lines[0].end_x - end_before).abs() < 1e-6, "the marker takes no space");
let o = &ft.objects[0];
// The frame's inner space is the spread's here (no rotation or offset).
let margin_x = d.spreads[0].pages[0].margin_rect().x0;
assert!((o.origin.x + o.size.0 - (margin_x - 6.0)).abs() < 1e-6, "right edge 6 pt left of the margin: {:?}", o.origin);
assert!((o.origin.y - ft.lines[0].baseline).abs() < 1e-6);
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap();
let obj = back.stories.values().flat_map(|st| st.objects.iter()).next().unwrap();
assert!(
matches!(obj.position, designcraft_doc::AnchorPosition::Custom { x_offset, object_point: 2, .. } if x_offset == -6.0),
"IDML Anchored position"
);
}
}
+18
View File
@@ -1763,6 +1763,24 @@ impl<'a> Ex<'a> {
)
.attr("AnchorSpaceAbove", num(*space_before))
.attr("AnchorYoffset", num(*space_after)),
designcraft_doc::AnchorPosition::Custom {
x_relative,
y_relative,
x_offset,
y_offset,
object_point,
ref_point,
keep_within_column,
} => El::new("AnchoredObjectSetting")
.attr("AnchoredPosition", "Anchored")
.attr("AnchorPoint", names::anchor_out(*object_point))
.attr("HorizontalReferencePoint", names::anchor_rel_out(*x_relative, false))
.attr("VerticalReferencePoint", names::anchor_rel_out(*y_relative, true))
.attr("HorizontalAlignment", ["LeftAlign", "CenterAlign", "RightAlign"][(*ref_point % 3) as usize])
.attr("VerticalAlignment", ["TopAlign", "CenterAlign", "BottomAlign"][(*ref_point / 3).min(2) as usize])
.attr("AnchorXoffset", num(*x_offset))
.attr("AnchorYoffset", num(*y_offset))
.attr("PinPosition", bool_s(*keep_within_column)),
};
e.push(setting);
out.push(Node::El(e));
+22
View File
@@ -1539,6 +1539,28 @@ impl<'r> Importer<'r> {
space_before: get("AnchorSpaceAbove"),
space_after: get("AnchorYoffset"),
},
Some("Anchored") => {
let s = |k: &str| set.and_then(|x| x.get(k)).unwrap_or("");
let col = match s("HorizontalAlignment") {
"CenterAlign" => 1,
"RightAlign" => 2,
_ => 0,
};
let row = match s("VerticalAlignment") {
"CenterAlign" => 1,
"BottomAlign" => 2,
_ => 0,
};
designcraft_doc::AnchorPosition::Custom {
x_relative: names::anchor_rel_in(s("HorizontalReferencePoint")),
y_relative: names::anchor_rel_in(s("VerticalReferencePoint")),
x_offset: get("AnchorXoffset"),
y_offset: get("AnchorYoffset"),
object_point: names::anchor_in(s("AnchorPoint")),
ref_point: row * 3 + col,
keep_within_column: s("PinPosition") == "true",
}
}
_ => designcraft_doc::AnchorPosition::Inline { y_offset: get("AnchorYoffset") },
};
b.objects.push(designcraft_doc::AnchoredObject::new(item, position));
+23
View File
@@ -21,6 +21,29 @@ const ANCHORS: [&str; 9] = [
"BottomRightAnchor",
];
pub fn anchor_rel_out(r: designcraft_doc::anchored::AnchorRelative, vertical: bool) -> &'static str {
use designcraft_doc::anchored::AnchorRelative as R;
match r {
R::Anchor if vertical => "LineBaseline",
R::Anchor => "AnchorLocation",
R::TextFrame => "TextFrame",
R::ColumnEdge => "ColumnEdge",
R::PageMargin => "PageMargins",
R::PageEdge => "PageEdge",
}
}
pub fn anchor_rel_in(s: &str) -> designcraft_doc::anchored::AnchorRelative {
use designcraft_doc::anchored::AnchorRelative as R;
match s {
"TextFrame" => R::TextFrame,
"ColumnEdge" => R::ColumnEdge,
"PageMargins" => R::PageMargin,
"PageEdge" => R::PageEdge,
_ => R::Anchor,
}
}
pub fn anchor_out(i: u8) -> &'static str {
ANCHORS[(i as usize).min(8)]
}
+3 -3
View File
@@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 87 | 9 | 1 | 94% |
| P1 | 97 | 88 | 8 | 1 | 95% |
| P2 | 76 | 23 | 13 | 40 | 39% |
| Area | Parity |
@@ -24,7 +24,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Effects & transparency | 71% |
| Placing & links | 82% |
| Type & text frames | 94% |
| Typography | 94% |
| Typography | 96% |
| Styles | 97% |
| Tables | 92% |
| Long documents | 88% |
@@ -233,7 +233,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Typography | Glyph scaling, letter/word spacing | P0 | D |
| Typography | Auto leading | P0 | D |
| Typography | Text wrap | P0 | D |
| Typography | Anchored objects | P1 | P |
| Typography | Anchored objects | P1 | D |
| Typography | Hyphenation exceptions | P1 | D |
| Typography | Language support | P1 | P |
| Styles | Paragraph styles | P0 | D |