mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 13:46:52 -06:00
T: custom anchored object positions (+ IDML)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
615b033f47
commit
2fefab232c
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user