From 54ea1559d79b8414724a9df8a746e7da65cf2a3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 14:33:21 +0000 Subject: [PATCH 1/9] Parent pages: show running-header placeholders, keep page overlays off them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a parent page a running-header variable has no page to read, so it composed blank; it now shows , as the header's placeholder. While parents were edited, document frames weren't laid out and fell back to the identity canvas transform, so their hidden characters (a flood of ¶), spelling squiggles and other overlays were drawn over the right parent page. Items on spreads that aren't shown now have no canvas transform. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- ROADMAP.md | 1 + crates/compose/src/vars.rs | 11 ++++++----- crates/engine/src/cmd/variables.rs | 10 ++++++++++ crates/ui-egui/src/canvas.rs | 29 ++++++++++++++++++++++++++++- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..0588bca30 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-17** · Parent pages: a running-header variable shows its name in angle brackets on the parent (it was blank), and while parents are edited the document pages' hidden characters, spelling squiggles and other overlays no longer pile onto the right parent page. No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/compose/src/vars.rs b/crates/compose/src/vars.rs index 945d8bd04..dfb8928c7 100644 --- a/crates/compose/src/vars.rs +++ b/crates/compose/src/vars.rs @@ -139,11 +139,12 @@ pub fn values(doc: &Document, page: Option, running: Option<&RunningIndex .enumerate() .map(|(i, v)| match &v.kind { VarKind::RunningHeader { style, use_, character } => { - let body = match (running, page) { - (Some(r), Some(p)) => r.value(style, *character, *use_, p), - _ => String::new(), - }; - format!("{}{}{}", v.before, body, v.after) + match (running, page) { + (Some(r), Some(p)) => format!("{}{}{}", v.before, r.value(style, *character, *use_, p), v.after), + (None, Some(_)) => format!("{}{}", v.before, v.after), + // A parent page has no entries of its own: show which variable sits there. + (_, None) => format!("<{}>", v.name), + } } _ => doc.variable_value(i, page).unwrap_or_default(), }) diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index d2e513430..1ea789721 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -176,6 +176,11 @@ fn define(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + fn shown_on_parent(s: &Session, d: &designcraft_doc::Document, sid: designcraft_doc::StoryId) -> usize { + let cs = s.cache.get(d, sid, None); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + } + /// A parent-page page-number marker follows Numbering & Section Options: changing a section's /// start number renames its pages without changing their index or the number of sections, and /// the marker composed for a page shows the new name. @@ -352,6 +357,11 @@ mod tests { assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon"); let list = s.execute("variables.list", &json!({})).unwrap(); assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3")); + // On the parent page itself the header shows which variable it holds, not a blank. + let head = d.variable("Chapter Head").unwrap(); + assert_eq!(crate::compose::vars::values(&d, None, Some(&idx))[head], ""); + assert_eq!(crate::compose::vars::values(&d, Some(2), Some(&idx))[head], "Epsilon"); + assert!(shown_on_parent(&s, &d, sid) > p1, "the parent shows , longer than Alpha"); // Deleting a variable flattens its instances to text. s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap(); assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3")); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34e1aa2fd..1a8cd7728 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1014,7 +1014,10 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document /// Spread-space → canvas transform for an item at `loc`. fn item_canvas_xf(doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId) -> Option<(Affine, &'static str)> { let loc = doc.find(id)?; - Some((layout.xf(loc.spread) * doc.parent_xf(&loc), "")) + // Items on spreads that aren't laid out (document pages while parents are being edited, or + // the reverse) have no place on the canvas. + let slot = layout.slot(loc.spread)?; + Some((slot.xf * doc.parent_xf(&loc), "")) } fn path_screen(it: &Item, xf: &Xf, a: Affine) -> Vec> { @@ -3678,6 +3681,30 @@ mod tests { /// The always-on overset mark sits at the frame's out port, a step along its own right edge /// (so a rotated frame gets it inside its edge, not straight up from the corner), and a /// selected overset frame gets only the mark its ports draw. + #[test] + fn document_pages_have_no_place_on_the_parent_canvas() { + // Regression: while parents were edited, document frames fell back to the identity + // transform and their ¶ marks (and other overlays) piled onto the right parent page. + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2})).unwrap(); + let s = &mut app.session; + let body = + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "one\ntwo"})).unwrap()["id"].as_u64().unwrap(); + let head = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [36, 10, 300, 30], "content": "text", "text": "head"})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (body, head) = (designcraft_doc::ItemId(body), designcraft_doc::ItemId(head)); + let pages = CanvasLayout::new(&doc, false); + assert!(item_canvas_xf(&doc, &pages, body).is_some()); + assert!(item_canvas_xf(&doc, &pages, head).is_none()); + let parents = CanvasLayout::new(&doc, true); + assert!(item_canvas_xf(&doc, &parents, body).is_none(), "a page frame is not drawn over the parent"); + assert!(item_canvas_xf(&doc, &parents, head).is_some()); + } + #[test] fn overset_mark_sits_on_the_out_port_once() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 4e263ad821e1fee3dce2f2d46e63ba69a9fcdbde Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:39:39 +0000 Subject: [PATCH 2/9] Cross-references keep edited text and break at spaces InDesign keeps text typed over a cross-reference's generated text (until the cross-reference is updated), with its page number still live. The IDML importer dropped that text and generated it again from the format, so a credits list whose format reads "Last, First. ... YYYY. Volume X, issue Y." showed those placeholders instead of the names and dates typed in. - CrossRef gains `text` (edited text, `` for the live page) and `page` (the page last shown). Import keeps saved text that no longer reads as its format produces it; export writes it back with InDesign's XRefPageNumber variable. Setting a format drops the edits, as InDesign does. - Edited text whose destination is gone shows its saved page, not "??". - Spaces in a cross-reference's text are word spaces the line may break at (it was set as one unbreakable piece, so long ones broke mid-word). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- ROADMAP.md | 1 + crates/compose/src/shape.rs | 7 +++- crates/compose/src/tests.rs | 31 +++++++++++++++++ crates/compose/src/xref.rs | 8 ++++- crates/doc/src/xref.rs | 65 ++++++++++++++++++++++++++++++++--- crates/engine/src/cmd/xref.rs | 7 ++-- crates/idml/src/export.rs | 31 +++++++++++++++-- crates/idml/src/import.rs | 59 +++++++++++++++++++++++++++---- crates/idml/src/tests.rs | 30 +++++++++++++++- 9 files changed, 221 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..f8c4bcf32 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-17** · Cross-references keep text typed over their generated text (IDML import and export), with the page number still live, as InDesign shows them until updated; a cross-reference whose destination is gone shows its saved text and page instead of "??"; and a cross-reference's text breaks at its spaces instead of inside words. No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index ffbc4857d..9f82e7d07 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -1175,7 +1175,12 @@ fn shape_segment( for (gi, sg) in shaped.iter().enumerate() { let up = upright.get(gi).copied().unwrap_or(false); let (byte, len, ch) = if replacement.is_some() { - (range.start, if gi == 0 { range.len() } else { 0 }, text[range.start..].chars().next().unwrap_or(' ')) + let marker = text[range.start..].chars().next().unwrap_or(' '); + // A cross-reference's text is real text in InDesign: its spaces are word spaces the + // line may break at (text variables and page numbers stay whole). The first glyph + // keeps the marker, as it owns the marker's bytes. + let space = gi > 0 && marker == designcraft_doc::XREF_MARK && src.get(sg.cluster..).is_some_and(|t| t.starts_with(' ')); + (range.start, if gi == 0 { range.len() } else { 0 }, if space { ' ' } else { marker }) } else { let cl = range.start + sg.cluster; let end = shaped[gi + 1..].iter().map(|r| range.start + r.cluster).find(|&c| c > cl).unwrap_or(range.end); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 9d8f6be70..0fb2cf4ba 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7242,3 +7242,34 @@ fn greek_text_gets_greek_hyphenation() { let el = hyphen::Lang::for_language("Greek").unwrap(); assert_eq!(hyphen::hyphen_points_in("ανθρωπος", &hyphen::Limits::default(), el), vec![2, 5]); } + +/// A cross-reference's text breaks at its spaces like ordinary text (it is real text in +/// InDesign), not inside words; edited text whose destination is gone shows its saved page. +#[test] +fn cross_reference_text_breaks_at_its_spaces() { + let (mut d, sid, _) = doc_with("See X.", Rect::new(36.0, 36.0, 110.0, 400.0), ParaAttrs::default()); + let at = d.story(sid).unwrap().text.find('X').unwrap(); + let st = d.story_mut(sid).unwrap(); + st.delete(at..at + 1); + st.insert_xref( + at, + designcraft_doc::CrossRef { + text: Some("Page : Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5.".into()), + page: Some("7".into()), + ..Default::default() + }, + ); + let x = d.story(sid).unwrap().xrefs[0].clone(); + let shown = crate::xref::xref_text(&d, &x, None); + assert_eq!(shown, "Page 7: Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5."); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines: Vec = + cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible).map(|g| g.rendered_char).collect::()).collect(); + assert!(lines.len() > 3, "{lines:?}"); + let words: Vec<&str> = format!("See {shown}.").leak().split(' ').collect(); + for l in &lines { + for w in l.split(' ').filter(|w| !w.is_empty()) { + assert!(words.contains(&w), "a line breaks inside a word: {w:?} in {lines:?}"); + } + } +} diff --git a/crates/compose/src/xref.rs b/crates/compose/src/xref.rs index b33714203..1d72f1ee1 100644 --- a/crates/compose/src/xref.rs +++ b/crates/compose/src/xref.rs @@ -52,8 +52,14 @@ impl XrefIndex { } /// The text a cross-reference shows ("??" when its destination is gone, like an unresolved -/// reference in typesetting). +/// reference in typesetting, unless the text was edited). pub fn xref_text(doc: &Document, x: &xref::CrossRef, index: Option<&XrefIndex>) -> String { + // Edited text stays as typed; its page numbers still follow the destination, or show the + // page last shown when the destination is gone (as in InDesign). + if let Some(t) = &x.text { + let live = index.filter(|_| doc.xref_values(x.target).is_some()).and_then(|i| i.page(x.target)); + return xref::expand_edited(t, live.or(x.page.as_deref()).unwrap_or("?")); + } let Some(mut v) = doc.xref_values(x.target) else { return "??".into() }; v.page = index.and_then(|i| i.page(x.target)).unwrap_or("?").to_string(); let def = doc.xref_format(&x.format).map_or("", |f| f.definition.as_str()); diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index 96e584862..07c195212 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -29,13 +29,58 @@ pub struct TextAnchor { pub name: String, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CrossRef { /// The destination anchor (0 = unresolved, e.g. pasted without its destination). pub target: u64, /// Name of the format in [`Document::xref_formats`]. pub format: String, + /// Text typed over the generated text (InDesign keeps a cross-reference's edited text until + /// it is updated): shown as is, except that each `` shows the destination's page. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + /// The page last shown (for edited text whose destination is gone). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page: Option, +} + +/// The `` building block, as it appears in [`CrossRef::text`]. +pub const PAGE_NUM_BLOCK: &str = ""; + +/// Edited cross-reference text with its page numbers filled in (quotes kept as typed). +pub fn expand_edited(text: &str, page: &str) -> String { + text.replace(PAGE_NUM_BLOCK, page) +} + +/// The literal text between a format's building blocks, in order. +pub fn literal_parts(definition: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut rest = definition; + while let Some(i) = rest.find('<') { + let Some(j) = rest[i..].find('>') else { break }; + if i > 0 { + out.push(&rest[..i]); + } + rest = &rest[i + j + 1..]; + } + if !rest.is_empty() { + out.push(rest); + } + out +} + +/// Does `text` (a cross-reference's text as saved) still read as its format would generate it: +/// every literal part of the definition, in order? Text that doesn't was edited by hand. +pub fn matches_format(text: &str, definition: &str) -> bool { + let mut rest = text; + for part in literal_parts(definition) { + match rest.find(part) { + Some(i) => rest = &rest[i + part.len()..], + None => return false, + } + } + true } /// A cross-reference format: building blocks between literal text. @@ -198,7 +243,7 @@ impl Story { if n > 0 { let k = self.marks_before(XREF_MARK, pos); for i in 0..n { - self.xrefs.insert(k + i, Arc::new(CrossRef { target: 0, format: String::new() })); + self.xrefs.insert(k + i, Arc::new(CrossRef::default())); } } let n = text.matches(crate::index::INDEX_MARK).count(); @@ -268,7 +313,7 @@ impl Story { let n = self.text.matches(XREF_MARK).count(); self.xrefs.truncate(n); while self.xrefs.len() < n { - self.xrefs.push(Arc::new(CrossRef { target: 0, format: String::new() })); + self.xrefs.push(Arc::new(CrossRef::default())); } let n = self.text.matches(crate::index::INDEX_MARK).count(); self.index_refs.truncate(n); @@ -388,6 +433,18 @@ pub fn clean(s: &str) -> String { mod tests { use super::*; + /// Saved text that still reads as its format produced it is generated again; text typed + /// over it is kept. + #[test] + fn edited_text_is_recognised() { + let def = "Page : Last, First. \".\" YYYY. Volume X, issue Y."; + assert_eq!(literal_parts(def), ["Page ", ": Last, First. \"", ".\" YYYY. Volume X, issue Y."]); + assert!(matches_format("Page : Last, First. \"Advent Horizon.\" YYYY. Volume X, issue Y.", def)); + assert!(!matches_format("Page : Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5.", def)); + assert!(matches_format("anything", "")); + assert_eq!(expand_edited("p. and ", "4"), "p. 4 and 4"); + } + #[test] fn formats_expand() { let v = @@ -407,7 +464,7 @@ mod tests { let mut s = Story::with_text(StoryId(3), "Heading\nBody text.", Default::default()); let a = s.insert_anchor(0, "Heading"); assert_eq!(a >> 24, 3); - s.insert_xref(13, CrossRef { target: a, format: "Page Number".into() }); + s.insert_xref(13, CrossRef { target: a, format: "Page Number".into(), ..Default::default() }); s.check().unwrap(); assert_eq!(s.anchor_pos(a), Some(0)); s.insert(0, "New "); diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs index d462f3d1b..1ce18254e 100644 --- a/crates/engine/src/cmd/xref.rs +++ b/crates/engine/src/cmd/xref.rs @@ -94,7 +94,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { let r = t2.range(); let r = r.start.min(st.len())..r.end.min(st.len()); st.delete(r.clone()); - st.insert_xref(r.start, CrossRef { target, format }); + st.insert_xref(r.start, CrossRef { target, format, ..Default::default() }); let pos = r.start + designcraft_doc::XREF_MARK.len_utf8(); sel.text = Some(TextSel { anchor: pos, focus: pos, ..t2 }); Ok(json!({"target": target, "pos": r.start})) @@ -161,7 +161,10 @@ fn set_format(s: &mut Session, p: &Value) -> Result { } let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let x = st.xrefs.get_mut(k).ok_or_else(|| bad("xref.setFormat", "no such cross-reference"))?; - std::sync::Arc::make_mut(x).format = format; + let x = std::sync::Arc::make_mut(x); + x.format = format; + // A new format generates the text again, replacing any edits (InDesign does the same). + x.text = None; st.rev = st.rev.wrapping_add(1); Ok(Value::Null) }) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index a773ec9e5..6b52c88af 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2478,9 +2478,34 @@ impl<'a> Ex<'a> { designcraft_doc::xref::expand(self.d.xref_format(&x.format).map_or("", |ff| ff.definition.as_str()), &v) }); let mut c = self.csr_el(&f); - let mut content = El::new("Content"); - content.children.push(Node::Text(text)); - c.push(content); + match &x.text { + // Edited text as typed, its page numbers as InDesign's live + // cross-reference page number. + Some(t) => { + let page = x.page.clone().unwrap_or_else(|| "?".into()); + for (n, part) in t.split(designcraft_doc::xref::PAGE_NUM_BLOCK).enumerate() { + if n > 0 { + c.push( + El::new("TextVariableInstance") + .attr("Self", self.fresh()) + .attr("Name", "TV XRefPageNumber") + .attr("ResultText", &page) + .attr("AssociatedTextVariable", "dTextVariablenTV XRefPageNumber"), + ); + } + if !part.is_empty() { + let mut content = El::new("Content"); + content.children.push(Node::Text(part.to_string())); + c.push(content); + } + } + } + None => { + let mut content = El::new("Content"); + content.children.push(Node::Text(text)); + c.push(content); + } + } let mut src = El::new("CrossReferenceSource") .attr("Self", &me) .attr("AppliedFormat", fmt) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 5cd3746a9..788019744 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2079,10 +2079,19 @@ impl<'r> Importer<'r> { anchor_list.push(Arc::new(designcraft_doc::TextAnchor { id: aid, name })); } let mut xref_list = Vec::new(); - for (k, (me, fmt)) in xrefs.into_iter().enumerate() { - self.xref_srcs.insert(me, (id, k)); - let format = self.xref_format_names.get(&fmt).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into()); - xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format })); + for (k, x) in xrefs.into_iter().enumerate() { + self.xref_srcs.insert(x.me, (id, k)); + let format = self.xref_format_names.get(&x.format).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into()); + // Text typed over the generated text is kept (InDesign shows it until the + // cross-reference is updated); text the format still produces is generated again. + let definition = self.xref_formats.iter().find(|f| f.name == format).map_or("", |f| f.definition.as_str()); + let edited = !x.text.is_empty() && !designcraft_doc::xref::matches_format(&x.text, definition); + xref_list.push(Arc::new(designcraft_doc::CrossRef { + target: 0, + format, + text: edited.then_some(x.text), + page: x.page.filter(|_| edited), + })); } let mut st = Story { id, @@ -2331,7 +2340,14 @@ impl<'r> Importer<'r> { } "CrossReferenceSource" => { // The generated text is regenerated from the format. - b.xrefs.push((c.get("Self").unwrap_or("").to_string(), c.get("AppliedFormat").unwrap_or("").to_string())); + let (mut text, mut page) = (String::new(), None); + xref_saved_text(c, &mut text, &mut page, 0); + b.xrefs.push(XrefSource { + me: c.get("Self").unwrap_or("").to_string(), + format: c.get("AppliedFormat").unwrap_or("").to_string(), + text, + page, + }); // A source at paragraph level wraps a range; inside a range it holds the // text directly and takes that range's format. let f = c.find("CharacterStyleRange").map_or_else(|| cf.clone(), |r| self.range_format(r, pchars, cf)); @@ -3377,10 +3393,41 @@ struct StoryBuilder { /// Text destinations (Self, name) and cross-reference sources (Self, format Self) in order. anchors: Vec<(String, String)>, index_refs: Vec, - xrefs: Vec<(String, String)>, + xrefs: Vec, objects: Vec, } +/// A cross-reference source as saved: its id, its format's id, and its text with each page number +/// as `` (and the page it last showed). +struct XrefSource { + me: String, + format: String, + text: String, + page: Option, +} + +/// The text a `CrossReferenceSource` holds, with its live page numbers (InDesign's +/// `XRefPageNumber` text variable) as ``. Returns the text and the page shown. +fn xref_saved_text(e: &El, text: &mut String, page: &mut Option, depth: usize) { + if depth > 16 { + return; + } + for c in e.elements() { + match c.local() { + "Content" => text.push_str(&c.text_content()), + "TextVariableInstance" if c.get("Name").is_some_and(|n| n.contains("XRefPageNumber")) => { + text.push_str(designcraft_doc::xref::PAGE_NUM_BLOCK); + if page.is_none() { + *page = c.get("ResultText").map(str::to_string); + } + } + "TextVariableInstance" => text.push_str(c.get("ResultText").unwrap_or("")), + "CharacterStyleRange" => xref_saved_text(c, text, page, depth + 1), + _ => {} + } + } +} + impl StoryBuilder { /// A table gets an anchor paragraph of its own. fn push_table(&mut self, t: Table, pf: &ParaFormat, cf: &CharFormat) { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 3f5b7977d..bfc58cfc0 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1154,7 +1154,7 @@ fn round_trips_cross_references() { d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Results: found\nSee .", ParaFormat::default()).unwrap(); let target = d.paragraph_anchor(sid, 0).unwrap(); let at = d.story(sid).unwrap().text.find(" .").unwrap() + 1; - d.story_mut(sid).unwrap().insert_xref(at, designcraft_doc::CrossRef { target, format: "Paragraph Text".into() }); + d.story_mut(sid).unwrap().insert_xref(at, designcraft_doc::CrossRef { target, format: "Paragraph Text".into(), ..Default::default() }); d.xref_formats.push(designcraft_doc::XrefFormat { name: "Mine".into(), definition: "(, p. )".into(), @@ -4189,3 +4189,31 @@ mod liquid_page_rules { } } } + +/// Text typed over a cross-reference's generated text is kept, its page number live (InDesign's +/// XRefPageNumber variable); text the format still produces is generated again. The edits +/// survive export and import. +#[test] +fn keeps_edited_cross_reference_text() { + let story = r#" + + +Page : Bove, Vinny. "Advent Horizon." 1996. + and +"Intro" on page +"#; + let d = import_idml(&fixture_with_story(story)).unwrap(); + d.check().unwrap(); + let s = d.stories.values().find(|s| !s.xrefs.is_empty()).expect("story with cross-references"); + assert_eq!(s.xrefs.len(), 2); + assert_eq!(s.xrefs[0].text.as_deref(), Some("Page : Bove, Vinny. \"Advent Horizon.\" 1996.")); + assert_eq!(s.xrefs[0].page.as_deref(), Some("7")); + assert_eq!(s.xrefs[1].text, None, "unedited text follows its format"); + // Its destination is gone: the page it last showed. + assert_eq!(designcraft_compose::xref::xref_text(&d, &s.xrefs[0], None), "Page 7: Bove, Vinny. \"Advent Horizon.\" 1996."); + let back = import_idml(&export_idml(&d)).unwrap(); + back.check().unwrap(); + let b = back.stories.values().find(|s| !s.xrefs.is_empty()).unwrap(); + assert_eq!(b.xrefs[0].text, s.xrefs[0].text); + assert_eq!(b.xrefs[0].page, s.xrefs[0].page); +} From 97b42e3476679d4c0da8cf0c6a1af40a811e4b0f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:03:09 +0000 Subject: [PATCH 3/9] Cross-references: the edited text's IDML export moves into its own method Keeps story_para under clippy's cognitive-complexity limit, which upstream now enforces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/idml/src/export/story.rs | 57 +++++++++++++++++---------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/crates/idml/src/export/story.rs b/crates/idml/src/export/story.rs index 4a067e402..1fcefd659 100644 --- a/crates/idml/src/export/story.rs +++ b/crates/idml/src/export/story.rs @@ -327,6 +327,34 @@ impl<'a> Ex<'a> { segs } + /// A cross-reference source's text: `generated`, or the text typed over it, its page numbers + /// as InDesign's live cross-reference page number. + fn xref_content(&mut self, c: &mut El, x: &designcraft_doc::CrossRef, generated: String) { + let Some(t) = &x.text else { + let mut content = El::new("Content"); + content.children.push(Node::Text(generated)); + c.push(content); + return; + }; + let page = x.page.clone().unwrap_or_else(|| "?".into()); + for (n, part) in t.split(designcraft_doc::xref::PAGE_NUM_BLOCK).enumerate() { + if n > 0 { + c.push( + El::new("TextVariableInstance") + .attr("Self", self.fresh()) + .attr("Name", "TV XRefPageNumber") + .attr("ResultText", &page) + .attr("AssociatedTextVariable", "dTextVariablenTV XRefPageNumber"), + ); + } + if !part.is_empty() { + let mut content = El::new("Content"); + content.children.push(Node::Text(part.to_string())); + c.push(content); + } + } + } + fn story_para(&mut self, s: &Story, pi: usize, r: std::ops::Range, last: bool) -> El { let pf = s.paras.get(pi).cloned().unwrap_or_default(); let mut psr = self.psr_el(&pf); @@ -424,34 +452,7 @@ impl<'a> Ex<'a> { designcraft_doc::xref::expand(self.d.xref_format(&x.format).map_or("", |ff| ff.definition.as_str()), &v) }); let mut c = self.csr_el(&f); - match &x.text { - // Edited text as typed, its page numbers as InDesign's live - // cross-reference page number. - Some(t) => { - let page = x.page.clone().unwrap_or_else(|| "?".into()); - for (n, part) in t.split(designcraft_doc::xref::PAGE_NUM_BLOCK).enumerate() { - if n > 0 { - c.push( - El::new("TextVariableInstance") - .attr("Self", self.fresh()) - .attr("Name", "TV XRefPageNumber") - .attr("ResultText", &page) - .attr("AssociatedTextVariable", "dTextVariablenTV XRefPageNumber"), - ); - } - if !part.is_empty() { - let mut content = El::new("Content"); - content.children.push(Node::Text(part.to_string())); - c.push(content); - } - } - } - None => { - let mut content = El::new("Content"); - content.children.push(Node::Text(text)); - c.push(content); - } - } + self.xref_content(&mut c, x, text); let mut src = El::new("CrossReferenceSource") .attr("Self", &me) .attr("AppliedFormat", fmt) From cdb2057aab9afb9ec787373fcf6cb327de6a7fce Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Fri, 30 Oct 2026 21:36:32 +0600 Subject: [PATCH 4/9] Rectangle dialog: width and height in the ruler units, not always picas (#1964) A click with a frame tool opened the Rectangle dialog in picas whatever Preferences > Units & Increments or the rulers' unit menus said. It now shows the width in the horizontal ruler unit and the height in the vertical one, and reads bare numbers typed in each field the same way. The properties panel and the control channel open it the same way. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/commands.rs | 1 + crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/dialogs.rs | 34 ++++++++++++++----- crates/ui-egui/src/dialogs/tests.rs | 34 +++++++++++++++++++ .../ui-egui/src/panels/properties/object.rs | 2 +- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 44e35ef15..bb75c102e 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -83,6 +83,7 @@ impl DesignApp { let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); match r { UiRequest::Dialog { id, params } if id == "colorTheme" => dialogs::open_color_theme(self, params), + UiRequest::Dialog { id, params } if id == "frameSize" => self.ui.dialog = Some(dialogs::frame_size_dialog(self, params)), UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)), UiRequest::View { params } => canvas::apply_view_request(self, ¶ms), UiRequest::Pick { purpose, params } => { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e4c6fe723..97a99f925 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -389,7 +389,8 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest if id == "findChange" || app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { app.session.reset_find_scope(); } - let d = crate::dialogs::Dialog::new(id, p.get("fields").cloned().unwrap_or(Value::Null)); + let fields = p.get("fields").cloned().unwrap_or(Value::Null); + let d = if id == "frameSize" { crate::dialogs::frame_size_dialog(app, fields) } else { crate::dialogs::Dialog::new(id, fields) }; let v = serde_json::to_value(&d).unwrap_or_default(); app.ui.dialog = Some(d); ok(v) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bb3077103..63619da5e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -79,13 +79,6 @@ impl Dialog { if id == "ruby" { fields.entry("section".to_string()).or_insert(json!("cjk.rubyPlacement")); } - if id == "frameSize" { - for k in ["width", "height"] { - if let Some(v) = fields.get(k).and_then(Value::as_f64) { - fields.insert(k.into(), json!(format_measure(v, Unit::Picas))); - } - } - } Dialog { id: id.into(), fields, unit: Unit::Picas } } /// Set a field and report a genuine Find domain/mode transition to the caller. @@ -106,9 +99,13 @@ impl Dialog { } } fn m(&self, k: &str) -> Option { + self.m_in(k, self.unit) + } + /// A measure where a bare number is in `unit`. + fn m_in(&self, k: &str, unit: Unit) -> Option { match self.fields.get(k) { Some(Value::Number(n)) => n.as_f64(), - Some(Value::String(s)) => parse_measure(s, self.unit).ok(), + Some(Value::String(s)) => parse_measure(s, unit).ok(), _ => None, } } @@ -934,6 +931,25 @@ fn doc_unit(app: &DesignApp) -> Unit { app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } +/// The document's vertical ruler unit: what a bare number means in a height field. +fn doc_vertical_unit(app: &DesignApp) -> Unit { + app.session.active().map(|s| s.doc.settings.vertical_units).unwrap_or(Unit::Picas) +} + +/// The Rectangle dialog a click with a frame tool opens: the width (points in `params`) shown in +/// the horizontal ruler unit, the height in the vertical one, as Preferences › Units & +/// Increments and the rulers' menus set them (#1964). +pub fn frame_size_dialog(app: &DesignApp, params: Value) -> Dialog { + let mut d = Dialog::new("frameSize", params); + for (k, unit) in [("width", doc_unit(app)), ("height", doc_vertical_unit(app))] { + if let Some(v) = d.fields.get(k).and_then(Value::as_f64) { + d.fields.insert(k.into(), json!(format_measure(v, unit))); + } + } + d.unit = doc_unit(app); + d +} + /// Layout › Create Guides…: guides go on the page in view (or the parent page being edited), not /// on the first page, and the gutters are typed in the document's ruler unit. pub fn open_create_guides(app: &mut DesignApp) { @@ -3277,7 +3293,7 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result { let x = d.fields.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = d.fields.get("y").and_then(Value::as_f64).unwrap_or(0.0); let w = d.m("width").unwrap_or(72.0); - let h = d.m("height").unwrap_or(72.0); + let h = d.m_in("height", doc_vertical_unit(app)).unwrap_or(72.0); app.run( "frame.create", json!({"spread": d.fields.get("spread").cloned().unwrap_or(json!(0)), "shape": d.s("shape"), "content": d.s("content"), "rect": [x, y, x + w, y + h]}), diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 84cbf3bea..0bd29e02d 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,37 @@ fn character_style_options_set_the_digits() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(char_style(&app, "Emphasis").unwrap().chars.digits, Some(designcraft_doc::Digits::Farsi)); } + +/// The Rectangle dialog a frame-tool click opens shows the width in the horizontal ruler unit and +/// the height in the vertical one, not always picas, and reads bare numbers the same way (#1964). +#[test] +fn frame_size_dialog_follows_the_ruler_units() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": "inches", "verticalUnits": "millimeters"})).unwrap(); + // As the frame tool asks: a click at (36, 36), 72 pt by 72 pt. + app.session.ui_requests.push(designcraft_engine::UiRequest::Dialog { + id: "frameSize".into(), + params: json!({"spread": 0, "shape": "rectangle", "content": "graphic", "x": 36.0, "y": 36.0, "width": 72.0, "height": 72.0}), + }); + app.after_engine(); + let d = app.ui.dialog.as_ref().expect("the Rectangle dialog"); + assert_eq!((d.id.as_str(), d.unit), ("frameSize", Unit::Inches)); + assert_eq!(d.s("width"), format_measure(72.0, Unit::Inches)); + assert_eq!(d.s("height"), format_measure(72.0, Unit::Millimeters)); + assert!(!d.s("width").contains('p') && !d.s("height").contains('p'), "no picas: {} x {}", d.s("width"), d.s("height")); + // Bare numbers typed in each field are in that field's unit: 2 in by 50 mm. + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("width".into(), json!("2")); + d.fields.insert("height".into(), json!("50")); + let before = app.session.doc().unwrap().doc.clone(); + confirm(&mut app).unwrap(); + let after = &app.session.doc().unwrap().doc; + let added: Vec<_> = after.spreads[0].items.iter().filter(|i| !before.spreads[0].items.iter().any(|b| b.id == i.id)).collect(); + assert_eq!(added.len(), 1, "one frame"); + let b = added[0].bounds(); + assert!((b.width() - 144.0).abs() < 0.01 && (b.height() - 50.0 / 25.4 * 72.0).abs() < 0.01, "{b:?}"); + // The properties panel's link opens it in the same units. + let d = frame_size_dialog(&app, json!({"width": 36.0, "height": 36.0})); + assert_eq!((d.s("width"), d.s("height")), (format_measure(36.0, Unit::Inches), format_measure(36.0, Unit::Millimeters))); +} diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index 3819894fe..6b142e0d4 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -327,7 +327,7 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) let b = block(ui, FIELD_H); icons::paint(ui.painter(), sub(b, 3.0, 1.0, 19.0, 19.0), "corner", t.accent); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Corner"), 56.0)).clicked() { - app.ui.dialog = Some(crate::dialogs::Dialog::new("frameSize", json!({}))); + app.ui.dialog = Some(crate::dialogs::frame_size_dialog(app, json!({}))); } let (shape, size) = i.corner; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { From e1d072f3bc1132199d9852a5d74600207fe16179 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:56:41 +0000 Subject: [PATCH 5/9] Parent pages: this branch keeps only the canvas fix The running-header placeholder on parent pages moves to its own branch (running-header-placeholder), as asked in review, so each lands on its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- ROADMAP.md | 2 +- crates/compose/src/vars.rs | 11 +++++------ crates/engine/src/cmd/variables/tests.rs | 10 ---------- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1118a9555..b0fce1ba0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -462,7 +462,7 @@ Newest first. - **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-17** · Inline and anchored objects paint under their frame's text, on screen and in PDF, as InDesign draws them: a picture whose frame reaches into the next line (a negative Y offset) no longer hides the top of that line. No readiness percentage change. - **2026-10-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. No readiness percentage change. -- **2026-10-17** · Parent pages: a running-header variable shows its name in angle brackets on the parent (it was blank), and while parents are edited the document pages' hidden characters, spelling squiggles and other overlays no longer pile onto the right parent page. No readiness percentage change. +- **2026-10-17** · Parent pages: while parents are edited, the document pages' hidden characters, spelling squiggles and other overlays no longer pile onto the right parent page. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/compose/src/vars.rs b/crates/compose/src/vars.rs index f8f5eef2a..508ac4a3e 100644 --- a/crates/compose/src/vars.rs +++ b/crates/compose/src/vars.rs @@ -165,12 +165,11 @@ pub fn values(doc: &Document, page: Option, running: Option<&RunningIndex .enumerate() .map(|(i, v)| match &v.kind { VarKind::RunningHeader { style, use_, character } => { - match (running, page) { - (Some(r), Some(p)) => format!("{}{}{}", v.before, r.value(style, *character, *use_, p), v.after), - (None, Some(_)) => format!("{}{}", v.before, v.after), - // A parent page has no entries of its own: show which variable sits there. - (_, None) => format!("<{}>", v.name), - } + let body = match (running, page) { + (Some(r), Some(p)) => r.value(style, *character, *use_, p), + _ => String::new(), + }; + format!("{}{}{}", v.before, body, v.after) } _ => doc.variable_value(i, page).unwrap_or_default(), }) diff --git a/crates/engine/src/cmd/variables/tests.rs b/crates/engine/src/cmd/variables/tests.rs index 35efa12ad..75c4bcaed 100644 --- a/crates/engine/src/cmd/variables/tests.rs +++ b/crates/engine/src/cmd/variables/tests.rs @@ -1,10 +1,5 @@ use super::*; -fn shown_on_parent(s: &Session, d: &designcraft_doc::Document, sid: designcraft_doc::StoryId) -> usize { - let cs = s.cache.get(d, sid, None); - cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() -} - /// A parent-page page-number marker follows Numbering & Section Options: changing a section's /// start number renames its pages without changing their index or the number of sections, and /// the marker composed for a page shows the new name. @@ -355,11 +350,6 @@ fn running_header_on_parent_page_follows_headings() { assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon"); let list = s.execute("variables.list", &json!({})).unwrap(); assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3")); - // On the parent page itself the header shows which variable it holds, not a blank. - let head = d.variable("Chapter Head").unwrap(); - assert_eq!(crate::compose::vars::values(&d, None, Some(&idx))[head], ""); - assert_eq!(crate::compose::vars::values(&d, Some(2), Some(&idx))[head], "Epsilon"); - assert!(shown_on_parent(&s, &d, sid) > p1, "the parent shows , longer than Alpha"); // Deleting a variable flattens its instances to text. s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap(); assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3")); From 26253e9da4cebba209913a271590dad64b25cb73 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:02:10 +0000 Subject: [PATCH 6/9] Parent pages: running headers show their name on the parent itself only On a parent page a running-header variable shows , as InDesign shows it (it was blank). Text with no page that isn't on a parent (a frame composition can't place on a page) keeps the variable's text before and after: frames now record whether they are on a parent spread, and the composed values are cached by page and parent. Split from #1232 (parent-page-display), as asked in review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 14 ++++++++++---- crates/compose/src/notes.rs | 1 + crates/compose/src/table.rs | 1 + crates/compose/src/vars.rs | 8 ++++++++ crates/engine/src/cmd/variables/tests.rs | 13 +++++++++++++ 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ad3f52a14..3f42560c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -384,6 +384,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. - **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change. - **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change. - **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 6c8bc1206..e42d790aa 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -579,8 +579,10 @@ pub struct FrameSpec { pub page_name: Option, /// Marker text of the section the frame's page is in (None on parent pages). pub section_marker: Option, - /// Absolute document page the frame is on (None on parent pages). + /// Absolute document page the frame is on (None on parent pages and the pasteboard). pub page: Option, + /// The frame is on a parent spread. + pub parent: bool, /// Baseline grid in inner space: (first grid line y, increment). pub grid: Option<(f64, f64)>, /// The frame is on a left page (for towards/away-from-spine alignment). @@ -815,6 +817,7 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId page_name, section_marker, page, + parent: matches!(spread_ref, Some(designcraft_doc::SpreadRef::Parent(_))), grid, left_page, right_page, @@ -860,7 +863,7 @@ struct SubstitutionInput<'a> { frame: Option<&'a FrameSpec>, opts: &'a ComposeOptions, var_story: bool, - var_cache: &'a mut HashMap, Arc>>, + var_cache: &'a mut HashMap<(Option, bool), Arc>>, notes: &'a mut Notes, } @@ -868,7 +871,9 @@ fn paragraph_substitution(input: SubstitutionInput<'_>) -> SubstCtx { let SubstitutionInput { doc, story, range: prange, props: pp, frame: cur_frame, opts, var_story, var_cache, notes } = input; let var_values = if var_story { let page = opts.page.or_else(|| cur_frame.and_then(|f| f.page)); - var_cache.entry(page).or_insert_with(|| std::sync::Arc::new(vars::values(doc, page, opts.running.as_deref()))).clone() + // A parent page itself (not composed for one of the pages it applies to). + let parent = page.is_none() && cur_frame.is_some_and(|f| f.parent); + var_cache.entry((page, parent)).or_insert_with(|| std::sync::Arc::new(vars::values_shown(doc, page, parent, opts.running.as_deref()))).clone() } else { Default::default() }; @@ -2674,7 +2679,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let var_story = vars::has_vars(story); let mut notes = Notes::new(doc, story); let mut note_snaps: Vec<(u32, Option, usize)> = Vec::with_capacity(np); - let mut var_cache: std::collections::HashMap, std::sync::Arc>> = Default::default(); + let mut var_cache: std::collections::HashMap<(Option, bool), std::sync::Arc>> = Default::default(); // Column balancing above spanning paragraphs: settled and trial bottoms by band, the search // in progress, and a budget for the re-lays. let mut limits: Rc> = Rc::default(); @@ -5895,6 +5900,7 @@ mod column_allocation_safety_tests { page_name: None, section_marker: None, page: None, + parent: false, grid: None, left_page: false, right_page: false, diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index a385a9093..1e1c852a5 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -176,6 +176,7 @@ impl Notes { page_name: f.page_name.clone(), section_marker: f.section_marker.clone(), page: f.page, + parent: f.parent, grid: None, left_page: f.left_page, right_page: f.right_page, diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index dc579d8a7..a124b1fce 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -132,6 +132,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef page_name: opts.page_name.clone(), section_marker: opts.section_marker.clone(), page: opts.page, + parent: false, grid: None, left_page, right_page, diff --git a/crates/compose/src/vars.rs b/crates/compose/src/vars.rs index 508ac4a3e..bd9e32ccd 100644 --- a/crates/compose/src/vars.rs +++ b/crates/compose/src/vars.rs @@ -160,10 +160,18 @@ impl RunningIndex { /// Values of every variable on `page`. pub fn values(doc: &Document, page: Option, running: Option<&RunningIndex>) -> Vec { + values_shown(doc, page, false, running) +} + +/// [`values`] as text shows them: on a parent page itself (`parent`, with no `page`), a running +/// header has no entries of its own and shows which variable sits there, ``, as InDesign +/// does. Elsewhere without a page (the pasteboard), it is its text before and after. +pub fn values_shown(doc: &Document, page: Option, parent: bool, running: Option<&RunningIndex>) -> Vec { doc.text_variables .iter() .enumerate() .map(|(i, v)| match &v.kind { + VarKind::RunningHeader { .. } if parent && page.is_none() => format!("<{}>", v.name), VarKind::RunningHeader { style, use_, character } => { let body = match (running, page) { (Some(r), Some(p)) => r.value(style, *character, *use_, p), diff --git a/crates/engine/src/cmd/variables/tests.rs b/crates/engine/src/cmd/variables/tests.rs index 75c4bcaed..9093f31dd 100644 --- a/crates/engine/src/cmd/variables/tests.rs +++ b/crates/engine/src/cmd/variables/tests.rs @@ -1,5 +1,10 @@ use super::*; +fn shown_on_parent(s: &Session, d: &designcraft_doc::Document, sid: designcraft_doc::StoryId) -> usize { + let cs = s.cache.get(d, sid, None); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() +} + /// A parent-page page-number marker follows Numbering & Section Options: changing a section's /// start number renames its pages without changing their index or the number of sections, and /// the marker composed for a page shows the new name. @@ -350,6 +355,14 @@ fn running_header_on_parent_page_follows_headings() { assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon"); let list = s.execute("variables.list", &json!({})).unwrap(); assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3")); + // On the parent page itself the header shows which variable it holds, not a blank. Text with + // no page that isn't on a parent shows the variable's text before and after, as before. + let head = d.variable("Chapter Head").unwrap(); + assert_eq!(crate::compose::vars::values_shown(&d, None, true, Some(&idx))[head], ""); + assert_eq!(crate::compose::vars::values_shown(&d, None, false, Some(&idx))[head], ""); + assert_eq!(crate::compose::vars::values(&d, None, Some(&idx))[head], "", "values() is the no-parent case"); + assert_eq!(crate::compose::vars::values_shown(&d, Some(2), true, Some(&idx))[head], "Epsilon", "composed for a page: its entry"); + assert!(shown_on_parent(&s, &d, sid) > p1, "the parent shows , longer than Alpha"); // Deleting a variable flattens its instances to text. s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap(); assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3")); From 239ac1bbb2c3a9fcc5b6c30fa9918e3d2628e585 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:02:17 +0000 Subject: [PATCH 7/9] Cross-references: saved text counts as generated only on an exact match An imported cross-reference's text is regenerated only when it is exactly what its format generates for its destination (Document::drop_generated_xref_text, once the destinations are resolved); "Page 7, revised" is an edit and is kept, as is the text of one whose destination is gone. The loose in-order match (matches_format) is gone. Page numbers are recognised by the instance's AssociatedTextVariable (a cross-reference page number variable, or InDesign's name for it when the file doesn't define it) rather than its Name, and text inside tracked-change wrappers counts as shown (insertions in, deletions out). Export writes generated text with its page numbers as the live variable too, so it reads back as generated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/doc/src/xref.rs | 68 +++++++++++++----------------- crates/idml/src/export/story.rs | 21 ++++----- crates/idml/src/import.rs | 6 +++ crates/idml/src/import/document.rs | 8 +++- crates/idml/src/import/story.rs | 36 +++++++++------- crates/idml/src/tests.rs | 37 +++++++++++++--- 6 files changed, 102 insertions(+), 74 deletions(-) diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index 07c195212..0ecd92dd5 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -53,36 +53,6 @@ pub fn expand_edited(text: &str, page: &str) -> String { text.replace(PAGE_NUM_BLOCK, page) } -/// The literal text between a format's building blocks, in order. -pub fn literal_parts(definition: &str) -> Vec<&str> { - let mut out = Vec::new(); - let mut rest = definition; - while let Some(i) = rest.find('<') { - let Some(j) = rest[i..].find('>') else { break }; - if i > 0 { - out.push(&rest[..i]); - } - rest = &rest[i + j + 1..]; - } - if !rest.is_empty() { - out.push(rest); - } - out -} - -/// Does `text` (a cross-reference's text as saved) still read as its format would generate it: -/// every literal part of the definition, in order? Text that doesn't was edited by hand. -pub fn matches_format(text: &str, definition: &str) -> bool { - let mut rest = text; - for part in literal_parts(definition) { - match rest.find(part) { - Some(i) => rest = &rest[i + part.len()..], - None => return false, - } - } - true -} - /// A cross-reference format: building blocks between literal text. /// /// Blocks: ``, `` (without the list number), ``, @@ -355,6 +325,35 @@ impl Story { } impl Document { + /// The text a cross-reference to `target` in `format` generates, with each page number as + /// [`PAGE_NUM_BLOCK`] (the form saved text takes); `None` when the destination is gone. + pub fn generated_xref_text(&self, target: u64, format: &str) -> Option { + let mut v = self.xref_values(target)?; + v.page = PAGE_NUM_BLOCK.into(); + Some(expand(self.xref_format(format).map_or("", |f| f.definition.as_str()), &v)) + } + + /// Forget saved cross-reference text that is exactly what its format generates for its + /// destination, so it is generated again (and follows edits to the destination). Any other + /// saved text was typed over the generated text, or its destination is gone: it is kept as + /// shown. + pub fn drop_generated_xref_text(&mut self) { + let generated: Vec<(StoryId, usize)> = self + .stories + .iter() + .flat_map(|(sid, st)| st.xrefs.iter().enumerate().map(move |(k, x)| (*sid, k, x))) + .filter(|(_, _, x)| x.text.as_ref().is_some_and(|t| self.generated_xref_text(x.target, &x.format).as_ref() == Some(t))) + .map(|(sid, k, _)| (sid, k)) + .collect(); + for (sid, k) in generated { + if let Some(x) = self.story_mut(sid).and_then(|st| st.xrefs.get_mut(k)) { + let x = Arc::make_mut(x); + x.text = None; + x.page = None; + } + } + } + /// (story, byte) of anchor `id`. pub fn find_anchor(&self, id: u64) -> Option<(StoryId, usize)> { if id == 0 { @@ -433,15 +432,8 @@ pub fn clean(s: &str) -> String { mod tests { use super::*; - /// Saved text that still reads as its format produced it is generated again; text typed - /// over it is kept. #[test] - fn edited_text_is_recognised() { - let def = "Page : Last, First. \".\" YYYY. Volume X, issue Y."; - assert_eq!(literal_parts(def), ["Page ", ": Last, First. \"", ".\" YYYY. Volume X, issue Y."]); - assert!(matches_format("Page : Last, First. \"Advent Horizon.\" YYYY. Volume X, issue Y.", def)); - assert!(!matches_format("Page : Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5.", def)); - assert!(matches_format("anything", "")); + fn edited_text_fills_in_its_page_numbers() { assert_eq!(expand_edited("p. and ", "4"), "p. 4 and 4"); } diff --git a/crates/idml/src/export/story.rs b/crates/idml/src/export/story.rs index 1fcefd659..9e013e0a4 100644 --- a/crates/idml/src/export/story.rs +++ b/crates/idml/src/export/story.rs @@ -327,14 +327,13 @@ impl<'a> Ex<'a> { segs } - /// A cross-reference source's text: `generated`, or the text typed over it, its page numbers - /// as InDesign's live cross-reference page number. - fn xref_content(&mut self, c: &mut El, x: &designcraft_doc::CrossRef, generated: String) { - let Some(t) = &x.text else { - let mut content = El::new("Content"); - content.children.push(Node::Text(generated)); - c.push(content); - return; + /// A cross-reference source's text: what its format generates, or the text typed over it, its + /// page numbers as InDesign's live cross-reference page number (as InDesign writes them, so + /// generated text reads back as generated). + fn xref_content(&mut self, c: &mut El, x: &designcraft_doc::CrossRef) { + let t = match &x.text { + Some(t) => t.clone(), + None => self.d.generated_xref_text(x.target, &x.format).unwrap_or_else(|| "??".into()), }; let page = x.page.clone().unwrap_or_else(|| "?".into()); for (n, part) in t.split(designcraft_doc::xref::PAGE_NUM_BLOCK).enumerate() { @@ -447,12 +446,8 @@ impl<'a> Ex<'a> { if let Some(x) = s.xrefs.get(k) { let me = self.fresh(); let fmt = self.d.xref_formats.iter().position(|ff| ff.name == x.format).map_or("n".to_string(), xref_format_self); - let text = self.d.xref_values(x.target).map_or("??".into(), |mut v| { - v.page = "?".into(); - designcraft_doc::xref::expand(self.d.xref_format(&x.format).map_or("", |ff| ff.definition.as_str()), &v) - }); let mut c = self.csr_el(&f); - self.xref_content(&mut c, x, text); + self.xref_content(&mut c, x); let mut src = El::new("CrossReferenceSource") .attr("Self", &me) .attr("AppliedFormat", fmt) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 2c6185c5c..535e32145 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -210,6 +210,8 @@ struct Importer<'r> { /// The file's text variables (`None`: it defines none) and their Self → index. text_vars: Option>, text_var_ids: HashMap, + /// The Self of each cross-reference page number variable (`XrefPageNumberType`). + xref_page_vars: HashSet, /// Creation and modification times (Unix seconds) from the package metadata. created: Option, modified: Option, @@ -283,6 +285,7 @@ impl<'r> Importer<'r> { index_xrefs: Vec::new(), text_vars: None, text_var_ids: HashMap::new(), + xref_page_vars: HashSet::new(), created: None, modified: None, repaired_tab_lists: 0, @@ -610,6 +613,9 @@ impl<'r> Importer<'r> { { d.settings.zero_point = zp; } + // Cross-reference text the format generates exactly is generated again; any other was + // typed over it and is kept (needs the destinations, resolved above). + d.drop_generated_xref_text(); Ok(d) } } diff --git a/crates/idml/src/import/document.rs b/crates/idml/src/import/document.rs index dce466381..cf2ba166c 100644 --- a/crates/idml/src/import/document.rs +++ b/crates/idml/src/import/document.rs @@ -133,7 +133,13 @@ impl<'r> Importer<'r> { pub(super) fn text_variable(&mut self, e: &El) { use designcraft_doc::vars::{TextVariable, Use, VAR_MAX, VarKind}; let ty = e.get("VariableType").unwrap_or(""); - // Cross-reference internals; export writes its own. + // Cross-reference internals; export writes its own. Their page numbers are recognised + // inside cross-reference text. + if ty == "XrefPageNumberType" + && let Some(id) = e.get("Self") + { + self.xref_page_vars.insert(id.to_string()); + } if ty.is_empty() || ty.starts_with("Xref") { return; } diff --git a/crates/idml/src/import/story.rs b/crates/idml/src/import/story.rs index b798266fe..579b1e004 100644 --- a/crates/idml/src/import/story.rs +++ b/crates/idml/src/import/story.rs @@ -50,16 +50,11 @@ impl<'r> Importer<'r> { for (k, x) in xrefs.into_iter().enumerate() { self.xref_srcs.insert(x.me, (id, k)); let format = self.xref_format_names.get(&x.format).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into()); - // Text typed over the generated text is kept (InDesign shows it until the - // cross-reference is updated); text the format still produces is generated again. - let definition = self.xref_formats.iter().find(|f| f.name == format).map_or("", |f| f.definition.as_str()); - let edited = !x.text.is_empty() && !designcraft_doc::xref::matches_format(&x.text, definition); - xref_list.push(Arc::new(designcraft_doc::CrossRef { - target: 0, - format, - text: edited.then_some(x.text), - page: x.page.filter(|_| edited), - })); + // The text as saved: once the destinations are known, text the format generates + // exactly is dropped to be generated again (`drop_generated_xref_text`), and text + // typed over it is kept, as InDesign shows it until the cross-reference is updated. + let saved = !x.text.is_empty(); + xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format, text: saved.then_some(x.text), page: x.page.filter(|_| saved) })); } let pref = e.find("StoryPreference"); let optical_margin = designcraft_doc::OpticalMargin { @@ -205,7 +200,7 @@ impl<'r> Importer<'r> { "CrossReferenceSource" => { // The generated text is regenerated from the format. let (mut text, mut page) = (String::new(), None); - xref_saved_text(c, &mut text, &mut page, 0); + xref_saved_text(c, &self.xref_page_vars, &mut text, &mut page, 0); b.xrefs.push(XrefSource { me: c.get("Self").unwrap_or("").to_string(), format: c.get("AppliedFormat").unwrap_or("").to_string(), @@ -431,23 +426,32 @@ struct XrefSource { page: Option, } -/// The text a `CrossReferenceSource` holds, with its live page numbers (InDesign's -/// `XRefPageNumber` text variable) as ``. Returns the text and the page shown. -fn xref_saved_text(e: &El, text: &mut String, page: &mut Option, depth: usize) { +/// Whether a `TextVariableInstance` shows a cross-reference page number: its +/// `AssociatedTextVariable` is a variable of that type (`page_vars`), or, in a file that doesn't +/// define it, has the name InDesign gives that variable. +fn is_xref_page_number(e: &El, page_vars: &HashSet) -> bool { + e.get("AssociatedTextVariable").is_some_and(|v| page_vars.contains(v) || (page_vars.is_empty() && v.ends_with("TV XRefPageNumber"))) +} + +/// The text a `CrossReferenceSource` holds as shown, with its live page numbers (InDesign's +/// cross-reference page number variable, `page_vars`) as ``. Tracked insertions count; +/// tracked deletions don't. Returns the text and the page shown. +fn xref_saved_text(e: &El, page_vars: &HashSet, text: &mut String, page: &mut Option, depth: usize) { if depth > 16 { return; } for c in e.elements() { match c.local() { "Content" => text.push_str(&c.text_content()), - "TextVariableInstance" if c.get("Name").is_some_and(|n| n.contains("XRefPageNumber")) => { + "TextVariableInstance" if is_xref_page_number(c, page_vars) => { text.push_str(designcraft_doc::xref::PAGE_NUM_BLOCK); if page.is_none() { *page = c.get("ResultText").map(str::to_string); } } "TextVariableInstance" => text.push_str(c.get("ResultText").unwrap_or("")), - "CharacterStyleRange" => xref_saved_text(c, text, page, depth + 1), + "Change" if c.get("ChangeType") == Some("DeletedText") => {} + "CharacterStyleRange" | "Change" => xref_saved_text(c, page_vars, text, page, depth + 1), _ => {} } } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index f6e48d2da..35ceec24a 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1311,6 +1311,7 @@ fn round_trips_cross_references() { assert_eq!(s.text, d.story(sid).unwrap().text); assert_eq!(s.xrefs.len(), 1); assert_eq!(s.xrefs[0].format, "Paragraph Text"); + assert_eq!(s.xrefs[0].text, None, "generated text reads back as generated"); let (tsid, tpos) = back.find_anchor(s.xrefs[0].target).expect("target resolves"); assert_eq!((tsid, tpos), (s.id, 0)); let mine = back.xref_format("Mine").unwrap(); @@ -4786,15 +4787,15 @@ fn epub_split_document_round_trips() { } } -/// Text typed over a cross-reference's generated text is kept, its page number live (InDesign's -/// XRefPageNumber variable); text the format still produces is generated again. The edits -/// survive export and import. +/// Text typed over a cross-reference's generated text is kept as shown, its page number live +/// (InDesign's cross-reference page number variable, found by its `AssociatedTextVariable`), +/// tracked insertions in and deletions out. The edits survive export and import. #[test] fn keeps_edited_cross_reference_text() { let story = r#" -Page : Bove, Vinny. "Advent Horizon." 1996. +Page : Bove, Vinny. "Advent Horizon." (draft) 1996. and "Intro" on page "#; @@ -4804,12 +4805,36 @@ fn keeps_edited_cross_reference_text() { assert_eq!(s.xrefs.len(), 2); assert_eq!(s.xrefs[0].text.as_deref(), Some("Page : Bove, Vinny. \"Advent Horizon.\" 1996.")); assert_eq!(s.xrefs[0].page.as_deref(), Some("7")); - assert_eq!(s.xrefs[1].text, None, "unedited text follows its format"); - // Its destination is gone: the page it last showed. + // Its destination is gone: the text and page it last showed. assert_eq!(designcraft_compose::xref::xref_text(&d, &s.xrefs[0], None), "Page 7: Bove, Vinny. \"Advent Horizon.\" 1996."); + assert_eq!(s.xrefs[1].text.as_deref(), Some("\"Intro\" on page "), "with no destination to generate from, the text as shown"); let back = import_idml(&export_idml(&d)).unwrap(); back.check().unwrap(); let b = back.stories.values().find(|s| !s.xrefs.is_empty()).unwrap(); assert_eq!(b.xrefs[0].text, s.xrefs[0].text); assert_eq!(b.xrefs[0].page, s.xrefs[0].page); } + +/// Saved cross-reference text counts as generated only when it is exactly what its format +/// generates for its destination: text added after it (or anywhere else) is an edit, and kept. +#[test] +fn only_exactly_generated_cross_reference_text_is_regenerated() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Results\nSee", ParaFormat::default()).unwrap(); + let target = d.paragraph_anchor(sid, 0).unwrap(); + d.xref_formats.push(designcraft_doc::XrefFormat { name: "Page".into(), definition: "Page ".into() }); + for t in ["Page , revised", "Page ", "Seite "] { + let st = d.story_mut(sid).unwrap(); + let end = st.len(); + st.insert(end, " "); + let x = designcraft_doc::CrossRef { target, format: "Page".into(), text: Some(t.into()), page: Some("1".into()) }; + st.insert_xref(end + 1, x); + } + d.check().unwrap(); + let back = import_idml(&export_idml(&d)).unwrap(); + back.check().unwrap(); + let s = back.stories.values().find(|s| !s.xrefs.is_empty()).unwrap(); + let got: Vec> = s.xrefs.iter().map(|x| x.text.as_deref()).collect(); + assert_eq!(got, [Some("Page , revised"), None, Some("Seite ")]); +} From 2e8bda16d0ee86ef913b1af3a62d3b1c9e501428 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Fri, 30 Oct 2026 23:08:46 +0600 Subject: [PATCH 8/9] Rectangle dialog: every ruler unit shows the size and reads it back (#1964) A test for all eleven ruler units (points to Ha): the width and height the dialog shows read back as the frame's size, so OK never resizes it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 0bd29e02d..5b5516c77 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3554,4 +3554,15 @@ fn frame_size_dialog_follows_the_ruler_units() { // The properties panel's link opens it in the same units. let d = frame_size_dialog(&app, json!({"width": 36.0, "height": 36.0})); assert_eq!((d.s("width"), d.s("height")), (format_measure(36.0, Unit::Inches), format_measure(36.0, Unit::Millimeters))); + // Every ruler unit: the shown size reads back as the size, so OK leaves a 72 pt frame 72 pt. + let units = serde_json::to_value(Unit::ALL).unwrap(); + for (unit, name) in Unit::ALL.iter().zip(units.as_array().unwrap()) { + app.run("document.preferences", json!({"horizontalUnits": name, "verticalUnits": name})).unwrap(); + let d = frame_size_dialog(&app, json!({"width": 72.0, "height": 145.0})); + assert_eq!(d.unit, *unit); + for (k, pt) in [("width", 72.0), ("height", 145.0)] { + let back = d.m_in(k, *unit).unwrap_or(f64::NAN); + assert!((back - pt).abs() < 0.05, "{name}: {k} shows {:?}, reads back {back}", d.s(k)); + } + } } From 486dcd53a2d12d49f373503040123da55789c408 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Fri, 30 Oct 2026 23:58:45 +0600 Subject: [PATCH 9/9] Rectangle dialog: the height keeps the unit it was shown in (#1964) The dialog kept its width unit from when it opened but read the height in the vertical ruler unit of the moment, so rulers switched while it was open (the control channel's document.preferences) turned a typed 50 mm into 50 in. It now keeps the vertical unit too. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 10 ++++++-- crates/ui-egui/src/dialogs/tests.rs | 38 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 63619da5e..b976887cd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -22,6 +22,10 @@ pub struct Dialog { /// Set from the active document when the dialog is shown or confirmed (picas without one). #[serde(skip)] pub unit: Unit, + /// The vertical ruler unit, for a dialog with a height field (the Rectangle dialog): kept + /// from when it opened, so a bare `50` means what it did then. `None` falls back to `unit`. + #[serde(skip)] + pub vertical_unit: Option, } impl Dialog { @@ -79,7 +83,7 @@ impl Dialog { if id == "ruby" { fields.entry("section".to_string()).or_insert(json!("cjk.rubyPlacement")); } - Dialog { id: id.into(), fields, unit: Unit::Picas } + Dialog { id: id.into(), fields, unit: Unit::Picas, vertical_unit: None } } /// Set a field and report a genuine Find domain/mode transition to the caller. pub(crate) fn set_field(&mut self, key: &str, value: Value) -> bool { @@ -947,6 +951,7 @@ pub fn frame_size_dialog(app: &DesignApp, params: Value) -> Dialog { } } d.unit = doc_unit(app); + d.vertical_unit = Some(doc_vertical_unit(app)); d } @@ -3293,7 +3298,8 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result { let x = d.fields.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = d.fields.get("y").and_then(Value::as_f64).unwrap_or(0.0); let w = d.m("width").unwrap_or(72.0); - let h = d.m_in("height", doc_vertical_unit(app)).unwrap_or(72.0); + // In the units the dialog showed, not ones changed while it was open. + let h = d.m_in("height", d.vertical_unit.unwrap_or(d.unit)).unwrap_or(72.0); app.run( "frame.create", json!({"spread": d.fields.get("spread").cloned().unwrap_or(json!(0)), "shape": d.s("shape"), "content": d.s("content"), "rect": [x, y, x + w, y + h]}), diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 5b5516c77..e195a8c23 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3566,3 +3566,41 @@ fn frame_size_dialog_follows_the_ruler_units() { } } } + +/// #2055 review: the Rectangle dialog reads the height in the vertical unit it showed when it +/// opened. Rulers switched while it is open (the control channel) leave a typed 50 at 50 mm. +#[test] +fn frame_size_dialog_keeps_its_units_while_open() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": "millimeters", "verticalUnits": "millimeters"})).unwrap(); + app.session.ui_requests.push(designcraft_engine::UiRequest::Dialog { + id: "frameSize".into(), + params: json!({"spread": 0, "shape": "rectangle", "content": "graphic", "x": 36.0, "y": 36.0, "width": 72.0, "height": 72.0}), + }); + app.after_engine(); + let d = app.ui.dialog.as_mut().expect("the Rectangle dialog"); + d.fields.insert("width".into(), json!("50")); + d.fields.insert("height".into(), json!("50")); + app.run("document.preferences", json!({"horizontalUnits": "inches", "verticalUnits": "inches"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + confirm(&mut app).unwrap(); + let after = &app.session.doc().unwrap().doc; + let added: Vec<_> = after.spreads[0].items.iter().filter(|i| !before.spreads[0].items.iter().any(|b| b.id == i.id)).collect(); + assert_eq!(added.len(), 1, "one frame"); + let b = added[0].bounds(); + let mm50 = 50.0 / 25.4 * 72.0; + assert!((b.width() - mm50).abs() < 0.01 && (b.height() - mm50).abs() < 0.01, "50 mm by 50 mm (about 141.73 pt): {b:?}"); + // A dialog made without the vertical unit (`Dialog::new`) reads the height in its own unit. + let mut d = Dialog::new("frameSize", json!({"width": "2", "height": "3"})); + d.unit = Unit::Inches; + assert_eq!(d.vertical_unit, None); + app.ui.dialog = Some(d); + let before = app.session.doc().unwrap().doc.clone(); + confirm(&mut app).unwrap(); + let after = &app.session.doc().unwrap().doc; + let added: Vec<_> = after.spreads[0].items.iter().filter(|i| !before.spreads[0].items.iter().any(|b| b.id == i.id)).collect(); + assert_eq!(added.len(), 1, "one frame"); + let b = added[0].bounds(); + assert!((b.width() - 144.0).abs() < 0.01 && (b.height() - 216.0).abs() < 0.01, "{b:?}"); +}