diff --git a/ROADMAP.md b/ROADMAP.md index 385f64c7f..6ccbb5229 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 56e1b4063..42046d2b4 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -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 { } _ => (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 { 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) + } }; } } diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index b0a561c7c..d806b5460 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -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(), diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index a512b3bac..af1762580 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -94,6 +94,8 @@ pub struct ObjectSpec { pub y_offset: Option, /// 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. diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 656488a23..b09d17ecd 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -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); diff --git a/crates/doc/src/anchored.rs b/crates/doc/src/anchored.rs index 61a03d219..c1392f313 100644 --- a/crates/doc/src/anchored.rs +++ b/crates/doc/src/anchored.rs @@ -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 { diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index fb3a04c45..59bd33afc 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "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 { "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 { 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::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 { + 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" + ); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 6305d459c..43ddf3e71 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -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)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 9766b1a70..03aff67f1 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -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)); diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 396e7edaf..2b74a2b5e 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -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)] } diff --git a/docs/parity.md b/docs/parity.md index a4e2cb1be..38eb8df6f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |