From 54305b11eb93e913addf96b0d1bdcd6bd4557d17 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Sat, 10 Oct 2026 18:45:56 +0000 Subject: [PATCH 01/41] Drop caps: scale with the cap's point size, as InDesign does A drop cap came out the same size whatever its point size: the fit (drop + body cap height) / (the cap's own cap height) cancels the cap's size, since its own cap height grows with it. InDesign fits the cap as if it were set at the body text's size and scales that by the cap's size over the body's, keeping its baseline: a larger cap rises above the first line, a smaller one falls short of it. Measured against an InDesign capture (Palatino, 14 pt body, 18 pt italic T, 2 lines): cap height 2.04x the line gap there, 2.03x predicted. The cap's box (selection highlight, caret) now reaches the top of a cap that rises above its line; selected glyphs are drawn inverted, so the part outside the box would vanish. A one-line cap still keeps its own size when that is smaller. Test: drop_cap_scales_with_its_point_size (a 15 pt cap over 10 pt text is 1.5x the fitted height on the same baseline, 7.5 pt is 0.75x, the body lines don't move, the box covers the cap). Before the fix the 15 pt cap was 30.7 pt, the same as at 10 pt; with it, 46.05 pt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/compose/src/lib.rs | 23 +++++++++++++++++------ crates/compose/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c6312e9f5..bfd29b39b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1133,7 +1133,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C drop_box = Some(DropCapBox { end: dc.end, baseline: baseline + dc.drop, - rect: Rect::new(at, baseline - asc, at + dc.width, baseline + dc.drop + desc), + // From its line's top, or its own top when it rises above that line. + rect: Rect::new(at, (baseline - asc).min(baseline + dc.drop - dc.cap), at + dc.width, baseline + dc.drop + desc), }); } ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback); @@ -2923,11 +2924,15 @@ struct DropCap { /// Its baseline below the first line's: `lines − 1` leadings of the text after it (grid steps /// on a baseline grid), wherever line `lines` is set. drop: f64, + /// Its cap height as set (how far its top sits above its baseline). + cap: f64, } /// Take the drop cap (the glyphs before byte `end`) off the front of a paragraph's glyphs and scale /// it so its cap height reaches from the first line's cap height down to line `lines`' baseline, -/// measured in the leading and cap height of the text after it. A one-line drop cap keeps its own +/// measured in the leading and cap height of the text after it: the size of a cap set at that text's +/// size. As in InDesign, a cap set larger (or smaller) is scaled by its size over that text's, +/// keeping its baseline, so it rises above the first line (or falls short of it). A one-line drop cap keeps its own /// size when that is smaller. On a baseline grid of increment `grid`, lines are that many grid /// steps apart. With `align_left` its ink starts at the indent (see [`DropCap::lsb`]). Tabs inside /// it advance to the stops of `tabs` (stops and the start-side indent; in right-to-left paragraphs @@ -2955,14 +2960,19 @@ fn split_drop_cap( let mut after: Vec = glyphs.drain(..spaces).collect(); let end = after.last().map_or(end, |g| g.byte.saturating_add(g.len).max(end)); let body = glyphs.iter().find(|g| g.len > 0 && g.cap > 0.0); - let (lead, cap) = body.map_or((base_leading, base_cap), |g| (g.leading, g.cap)); + let (lead, cap, body_size) = body.map_or((base_leading, base_cap, None), |g| (g.leading, g.cap, Some(g.size))); let lead = match grid.map(|inc| (lead / inc - 1e-6).ceil().max(1.0) * inc) { Some(pitch) if pitch.is_finite() && pitch > 0.0 => pitch, _ => lead, }; let drop = (lines - 1) as f64 * lead; - let own = dc.iter().map(|g| g.cap).fold(0.0, f64::max); - let k = if own > 0.0 { (drop + cap) / own } else { 1.0 }; + // The cap's tallest glyph sets its height; its point size over the body's scales the fit. + let (own, own_size) = dc.iter().fold((0.0, 0.0), |(c, s), g| if g.cap > c { (g.cap, g.size) } else { (c, s) }); + let ratio = match body_size { + Some(b) if b > 0.0 && own_size > 0.0 && (own_size / b).is_finite() => own_size / b, + _ => 1.0, + }; + let k = if own > 0.0 { (drop + cap) / own * ratio } else { 1.0 }; let k = if lines == 1 { k.min(1.0) } else { k }; let k = if k.is_finite() && k > 0.0 { k.min(1000.0) } else { 1.0 }; for g in dc.iter_mut().chain(after.iter_mut()) { @@ -3003,7 +3013,8 @@ fn split_drop_cap( } let width = dc.iter().map(|g| g.adv).sum(); dc.append(&mut after); - Some(DropCap { glyphs: dc, end, lines, width, drop, lsb }) + let cap = dc.iter().map(|g| g.cap).fold(0.0, f64::max); + Some(DropCap { glyphs: dc, end, lines, width, drop, lsb, cap }) } impl DropCap { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a20ec78b3..7f8918a12 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2493,6 +2493,37 @@ fn right_to_left_drop_cap_sets_its_tabs_from_the_right_edge() { assert!(right <= 260.0 + 1e-6 && right > 255.0, "text ends at {right}"); } +/// The cap's point size scales it, as in InDesign: at the body's size (10 pt) it fills its lines +/// exactly; set larger or smaller it is scaled by its size over the body's, keeping its baseline, +/// so it rises above the first line (or falls short of it). The body's lines don't move, and the +/// cap's box (highlight, caret) reaches the top of a cap that rises above its line. +#[test] +fn drop_cap_scales_with_its_point_size() { + let set = |size: f64| { + let (mut d, sid, _) = drop_doc(&[LOREM; 2].join(" "), drop_cap(3, 1)); + d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(size)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs).into_iter().cloned().collect::>() + }; + let base = set(10.0); + let (b0, b2) = (base[0].baseline, base[2].baseline); + let body = base[0].glyphs.iter().find(|g| g.byte == 1).unwrap(); + let fitted = cap_of(&base[0].glyphs[0]); + assert!((b2 - fitted - (b0 - cap_of(body))).abs() < 0.01, "at the body's size the cap fills its lines"); + for (size, scale) in [(15.0, 1.5), (7.5, 0.75)] { + let l = set(size); + let dc = &l[0].glyphs[0]; + for (a, b) in l.iter().zip(&base) { + assert!((a.baseline - b.baseline).abs() < 1e-6, "{size} pt: the lines stay where they were"); + } + assert!((l[0].baseline + dc.y - b2).abs() < 1e-6, "{size} pt: the cap sits on line 3's baseline"); + assert!((cap_of(dc) - fitted * scale).abs() < 0.01, "{size} pt: cap height {} vs {}", cap_of(dc), fitted * scale); + let top = (l[0].baseline - l[0].ascent).min(b2 - cap_of(dc)); + assert!((l[0].drop_cap.unwrap().rect.y0 - top).abs() < 1e-6, "{size} pt: the box starts at the line's or the cap's top"); + } + assert!(b2 - fitted * 1.5 < b0 - base[0].ascent, "a 15 pt cap rises above the first line"); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test] From ed218b9817e79cfafa3277a123de5ef03859f8e9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 21:45:11 +0200 Subject: [PATCH 02/41] Text parameters: a vertical tab is a forced line break frame.create, story.setText, text.insert and the other commands that take text kept U+000B as a character, drawn as a missing-glyph box. It is the forced line break of Word and PowerPoint text (Chr(11), Shift+Enter), and what people porting scripts try first, so text_param now reads it as U+2028, the forced line break, next to the CR handling it already has. script --help and docs/agents.md say so. --- apps/designcraft-cli/src/main.rs | 1 + crates/engine/src/cmd/mod.rs | 9 +++++++-- crates/engine/src/cmd/object.rs | 16 ++++++++++++++++ docs/agents.md | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index c4373ffb6..d768bc34f 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -62,6 +62,7 @@ References to earlier results (in string parameters): "$$" a literal `$` anywhere: "$$1" passes `$1` (a GREP group), "$${0.id}" passes `${0.id}`. Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab. +"\u000b" (Word's line break, Chr(11)) is read as a forced line break too. Most commands ignore parameters they do not know instead of rejecting them: check names with `designcraft-cli describe COMMAND`. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 0bfb9727c..9b0acfe5a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -240,9 +240,14 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { } /// Plain text from a parameter, with CR LF and lone CR (classic Mac and InDesign line ends) as /// `\n`, the paragraph separator, as edit.paste and text import already do. Kept as they were, -/// the CRs joined the paragraphs into one. +/// the CRs joined the paragraphs into one. A vertical tab (Word's and PowerPoint's Shift+Enter, +/// Chr(11)) is a forced line break: kept, it was drawn as a missing-glyph box. pub(crate) fn text_param(p: &Value, key: &str) -> String { - str_param(p, key).unwrap_or("").replace("\r\n", "\n").replace('\r', "\n") + str_param(p, key) + .unwrap_or("") + .replace("\r\n", "\n") + .replace('\r', "\n") + .replace('\u{B}', designcraft_doc::story::FORCED_LINE_BREAK.encode_utf8(&mut [0; 4])) } pub(crate) fn id_param(p: &Value, key: &str) -> Option { p.get(key).and_then(Value::as_u64).map(ItemId) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index bbc0a36a1..fb014353a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3124,6 +3124,22 @@ mod line_end_tests { s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap(); assert_eq!(note(&mut s), "Three\nFour"); } + + /// "One\u{B}Two" kept the vertical tab, drawn as a missing-glyph box, where Word and + /// PowerPoint text (Chr(11)) means a forced line break. + #[test] + fn vertical_tabs_are_forced_line_breaks() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One\u{B}Two"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let story = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two", 1)); + s.execute("text.insert", &json!({"text": "\u{B}Three", "raw": true})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two\u{2028}Three", 1)); + s.execute("story.setText", &json!({"story": sid.0, "text": "A\u{B}B\rC"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\u{2028}B\nC", 2)); + } } #[cfg(test)] diff --git a/docs/agents.md b/docs/agents.md index b737ebb81..49ff7d5bc 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -26,7 +26,7 @@ of step N's result: N is the 0-based step index (comments and blank lines are no step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"` interpolates inside longer strings. Any other string starting with `$` stays as written (`"$1-$2"`, `"$5 off"`), and `$$` writes a literal `$` anywhere (`"$$1"` passes `$1`, a GREP group). In text parameters `\n` starts a new paragraph and `\u2028` is a forced line -break. Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`. +break (`\u000b`, Word's line break, is read as one too). Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`. `designcraft-cli script --help` prints this reference. ```sh From 230b29be60d6506f91c0f3ee7d4fa792750196f0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:54:41 +0000 Subject: [PATCH 03/41] Refuse table row moves across the header/footer boundary Found while testing crates/doc/src/table.rs:882 (Table::move_row, called from crates/engine/src/cmd/table.rs:1437 and :1369) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/table.rs | 17 ++++++++++++++++- crates/engine/src/cmd/table.rs | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 9e5f9c167..ae7bb1ef2 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -878,7 +878,8 @@ impl Table { } /// Move row `from` to position `to` (indices before the move). Refused when merged cells span - /// rows (the move would cut them). + /// rows (the move would cut them) or when the move would take a row across a header/footer + /// boundary (`from` and `to` must be the same kind of row). pub fn move_row(&mut self, from: usize, to: usize) -> Result<(), String> { let (nr, nc) = (self.nrows(), self.ncols()); if from >= nr || to >= nr { @@ -887,6 +888,9 @@ impl Table { if self.regions().iter().any(|r| r.2 > 1) { return Err("unmerge cells that span rows first".into()); } + if self.rows[from].kind != self.rows[to].kind { + return Err("header and footer rows must stay first and last".into()); + } let row = self.rows.remove(from); self.rows.insert(to, row); let cells: Vec = self.cells.drain(from * nc..(from + 1) * nc).collect(); @@ -1195,6 +1199,17 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + #[test] + fn move_row_keeps_header_and_footer_rows_in_place() { + let mut t = Table::new(1, 3, 2, 1, 1, 120.0); + for (from, to) in [(0, 2), (0, 4), (2, 0), (2, 4), (4, 2), (4, 0)] { + assert!(t.move_row(from, to).is_err(), "move_row({from}, {to}) should be refused"); + t.check().unwrap(); + } + t.move_row(1, 3).unwrap(); + t.check().unwrap(); + } + #[test] fn selected_owners_include_covered_cells_without_expanding_range() { for column in [true, false] { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 4b2962b4e..a8aaadb42 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1677,6 +1677,31 @@ mod sort_tests { assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string())); } + #[test] + fn move_row_cannot_move_a_header_row_out_of_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + assert!(s.execute("table.moveRow", &json!({"from": 0, "to": 2})).is_err()); + s.doc().unwrap().doc.check().unwrap(); + // Dragging the selected header row onto the last body row is refused too. + let d = s.doc().unwrap().doc.clone(); + let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap(); + let fid = d.stories[&sid].frames[0]; + let cs = s.cache.get(&d, sid, None); + let cells = &cs.frames[0].tables[0].cells; + let centre = |r: usize| { + let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); + d.text_xf(d.item(fid).unwrap()) * c + }; + let (from, to) = (centre(0), centre(2)); + s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); + assert!(s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).is_err()); + s.doc().unwrap().doc.check().unwrap(); + } + #[test] fn table_options_limits_header_and_footer_rows() { let mut s = Session::new(); From ec86a8fd3c29f2fa0061c4bf0a104333e6c7bb8e Mon Sep 17 00:00:00 2001 From: Youssef Azriouil Date: Wed, 14 Oct 2026 18:57:44 +0200 Subject: [PATCH 04/41] Breaker: keep a page number's digits together The emergency break inside a word wider than the measure could fall between glyphs of one source character: an automatic page number wider than its frame came out as "13" over "4". Glyphs sharing a cluster (the generated digits of a page number, multi-glyph clusters) now stay together and the number hangs out of its frame, as in InDesign. --- crates/compose/src/breaker.rs | 4 ++++ crates/compose/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index c2db91fb2..d5742f1da 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -462,7 +462,11 @@ fn overlong_words(items: &[Item], ig: &[usize], glyphs: &[Glyph], measure: f64) let marks = out.get_or_insert_with(|| vec![false; glyphs.len()]); for i in a..b { let (Some(g), Some(next)) = (glyphs.get(i), glyphs.get(i + 1)) else { continue }; + // Glyphs of one source character (an automatic page number's digits, a + // multi-glyph cluster) stay together, as InDesign keeps them. + let one_char = next.byte == g.byte && (next.len == 0 || next.len == g.len); let ok = !g.no_break + && !one_char && g.break_after != Some(false) && next.ch != SOFT_HYPHEN && !is_forced(next.ch) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 97eeb8cd4..e1710cb88 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -358,6 +358,23 @@ fn jump_object_keeps_grid_aligned_lines_on_the_grid() { assert!((l.baseline - want).abs() < 1e-6, "{} vs {want}", l.baseline); } +/// An automatic page number wider than its frame stays on one line and hangs out, as in InDesign +/// ("136" in a frame made for two digits); breaking it put "13" over "4". +#[test] +fn page_number_wider_than_its_frame_stays_whole() { + let text = designcraft_doc::story::PAGE_NUMBER.to_string(); + let (d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 10.0, 40.0), ParaAttrs::default()); + let mut specs = frame_specs(&d, sid); + specs[0].page_name = Some("136".into()); + let cs = compose(&d, d.story(sid).unwrap(), &specs, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert!(!cs.is_overset()); + // A word of ordinary letters that doesn't fit still breaks where it must. + let (d, sid, _) = doc_with("Wordwiderthanframe", Rect::new(0.0, 0.0, 30.0, 200.0), ParaAttrs::default()); + assert!(all_lines(&compose_story(&d, sid, &ComposeOptions::default())).len() > 1); +} + #[test] fn hostile_wrap_geometry_terminates() { let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, &[]); From e6878edac280597e06274fa7dd04d29e96a36bd3 Mon Sep 17 00:00:00 2001 From: Youssef Azriouil Date: Thu, 15 Oct 2026 08:37:40 +0200 Subject: [PATCH 05/41] Shaping: Arabic keeps contextual alternates Contextual alternates are part of how many Arabic fonts join (connecting strokes, tatweel and letter variants). InDesign keeps them in Arabic text when Contextual Alternates is off, so `calt` off is now dropped for Arabic runs. --- crates/fonts/src/lib.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index f31f59422..64b21d689 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -195,6 +195,20 @@ pub fn shape_with_context( out } +/// `features` as a run in `script` takes them. Contextual alternates are part of how Arabic joins +/// (connecting strokes, tatweel and letter variants): InDesign keeps them in Arabic text with +/// Contextual Alternates off, so `calt` off is dropped there. +fn script_features(features: &[Feature], script: unicode_script::Script) -> std::borrow::Cow<'_, [Feature]> { + use unicode_script::Script; + let calt = Tag::new(b"calt"); + let off = |f: &Feature| f.tag == calt && f.value == 0; + if script == Script::Arabic && features.iter().any(off) { + features.iter().filter(|f| !off(f)).copied().collect::>().into() + } else { + features.into() + } +} + fn shape_dir( face: &FontFace, text: &str, @@ -205,6 +219,8 @@ fn shape_dir( context: ShapeContext<'_>, ) -> Vec { let mut out = Vec::with_capacity(text.len()); + let features = script_features(features, script); + let features = &features[..]; let shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); let mut buf = UnicodeBuffer::new(); @@ -332,6 +348,16 @@ pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { mod tests { use super::*; + #[test] + fn arabic_keeps_contextual_alternates() { + use unicode_script::Script; + let off = feature("-calt").unwrap(); + let liga = feature("-liga").unwrap(); + let tags = |s: Script| script_features(&[off, liga], s).iter().map(|f| (f.tag, f.value)).collect::>(); + assert_eq!(tags(Script::Arabic), vec![(liga.tag, 0)]); + assert_eq!(tags(Script::Latin), vec![(off.tag, 0), (liga.tag, 0)]); + } + #[test] fn arabic_script_runs_do_not_inherit_the_hebrew_shaper() { use unicode_script::Script; From 91a32730ef88214d3a9a3e9beac3548274ff1de9 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:02:23 -0400 Subject: [PATCH 06/41] fix: allow deleting parent pages (#956) --- ROADMAP.md | 4 +- crates/doc/src/build.rs | 49 ++++++- crates/engine/src/cmd/layout.rs | 25 ++++ crates/engine/src/cmd/parent_delete_tests.rs | 127 +++++++++++++++++++ crates/ui-egui/src/panels/pages.rs | 42 +++++- docs/agents.md | 9 ++ 6 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/parent_delete_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 2aacdaf9b..fa42b4c24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -214,7 +214,7 @@ same references), and from the app's JSON control channel. See [docs/agents.md]( | # | Milestone | State | **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (undoable parent deletion) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text stroke behind fill) · **Target:** Adobe InDesign 2026 @@ -343,6 +343,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **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-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. Newest first. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 9fb075553..43e0c7be0 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -1,7 +1,7 @@ //! Creating documents (File → New → Document), presets, and page/spread structure edits //! (insert/delete/move pages, repagination into spreads, parents). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; use designcraft_geom::{Affine, Unit, units}; @@ -251,6 +251,53 @@ impl Document { id } + /// Delete a parent spread and unapply it wherever it is used. Local overrides survive as + /// ordinary items; the deleted parent's own items leave their text threads normally. + pub fn delete_parent(&mut self, id: SpreadId) -> Result<()> { + let index = self.parent_index(id).ok_or_else(|| DocError::Invalid(format!("no parent {id}")))?; + let parent = self.parents.get(index).ok_or_else(|| DocError::Invalid(format!("no parent {id}")))?; + let roots: Vec<_> = parent.items.iter().map(|item| item.id).collect(); + let mut deleted = HashSet::new(); + for item in &parent.items { + item.walk(&mut |item| { + deleted.insert(item.id); + }); + } + for item in roots { + self.remove_item(item)?; + } + self.parents.remove(index); + + let mut detached = Vec::new(); + for spread in self.spreads.iter_mut().chain(self.parents.iter_mut()) { + let spread = Arc::make_mut(spread); + if let Some(parent) = &mut spread.parent + && parent.based_on == Some(id) + { + parent.based_on = None; + } + for page in &mut spread.pages { + if page.parent == Some(id) { + page.parent = None; + } + page.overridden.retain(|item| !deleted.contains(item)); + } + for item in &spread.items { + item.walk(&mut |item| { + if item.overrides.is_some_and(|source| deleted.contains(&source)) { + detached.push(item.id); + } + }); + } + } + for item in detached { + if let Some(item) = self.item_mut(item) { + item.overrides = None; + } + } + Ok(()) + } + pub fn parent_index(&self, id: SpreadId) -> Option { self.parents.iter().position(|p| p.id == id) } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index fe06ad3ee..f1313dc4e 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -246,6 +246,27 @@ pub fn specs() -> Vec { Ok(json!({"id": id.0, "prefix": prefix})) }) }), + cmd!( + "layout.parents.delete", + "Delete Parent", + [], + None, + "{id: parent spread id} — unapply the parent and keep local overrides as ordinary items", + has_doc, + |s, p| { + let id = p.get("id").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad("layout.parents.delete", "missing parent id"))?; + s.edit(|d, sel| { + d.delete_parent(id)?; + *sel = Default::default(); + ok() + })?; + let st = s.doc_mut()?; + if st.doc.parents.is_empty() { + st.editing_parents = false; + } + ok() + } + ), cmd!(noundo "layout.parents.edit", "Edit Parents", [], None, "{on: bool} — show parent spreads on the canvas", has_doc, |s, p| { let st = s.doc_mut()?; st.editing_parents = p.get("on").and_then(Value::as_bool).unwrap_or(!st.editing_parents); @@ -1264,3 +1285,7 @@ mod spread_rotation_input_tests { assert_eq!(rotation(&s), 0, "zero resets the spread view"); } } + +#[cfg(test)] +#[path = "parent_delete_tests.rs"] +mod parent_delete_tests; diff --git a/crates/engine/src/cmd/parent_delete_tests.rs b/crates/engine/src/cmd/parent_delete_tests.rs new file mode 100644 index 000000000..0bfd89e7a --- /dev/null +++ b/crates/engine/src/cmd/parent_delete_tests.rs @@ -0,0 +1,127 @@ +//! Regression coverage for deleting parent spreads through the command boundary. + +use std::sync::Arc; + +use designcraft_doc::{ItemId, Selection, SpreadId, TextSel}; +use serde_json::{Value, json}; + +use crate::Session; + +fn frame(s: &mut Session, parent: usize, text: Option<&str>) -> ItemId { + let mut p = json!({"spread": {"kind": "parent", "index": parent}, "rect": [36, 36, 200, 100]}); + if let Some(text) = text { + p["content"] = json!("text"); + p["text"] = json!(text); + } + ItemId(s.execute("frame.create", &p).unwrap()["id"].as_u64().unwrap()) +} + +#[test] +fn parent_delete_rejects_invalid_ids_without_editing_or_adding_history() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("layout.parents.edit", &json!({"on": true})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + for params in [json!({}), json!({"id": null}), json!({"id": "A"}), json!({"id": -1}), json!({"id": 999999})] { + assert!(s.execute("layout.parents.delete", ¶ms).is_err(), "{params}"); + let st = s.doc().unwrap(); + assert!(Arc::ptr_eq(&st.doc, &before)); + assert_eq!(st.history.undo.len(), history); + assert!(st.editing_parents); + } +} + +#[test] +fn parent_delete_preserves_local_copies_cleans_references_and_roundtrips_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let a = s.doc().unwrap().doc.parents[0].id; + let text = frame(&mut s, 0, Some("Parent folio")); + let story = s.doc().unwrap().doc.item(text).unwrap().text_frame().unwrap().story; + let shape = frame(&mut s, 0, None); + let group = ItemId(s.execute("object.group", &json!({"ids": [text.0, shape.0]})).unwrap()["id"].as_u64().unwrap()); + let local = ItemId(s.execute("layout.overrideParentItems", &json!({"page": 0})).unwrap()["ids"][0].as_u64().unwrap()); + let local_text = s.doc().unwrap().doc.item(local).unwrap().children().iter().find(|i| i.text_frame().is_some()).unwrap().id; + let local_story = s.doc().unwrap().doc.item(local_text).unwrap().text_frame().unwrap().story; + assert_ne!(story, local_story); + + let b = SpreadId(s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap()); + let c = SpreadId(s.execute("layout.parents.new", &json!({"prefix": "C"})).unwrap()["id"].as_u64().unwrap()); + let inherited = frame(&mut s, 1, Some("Local parent edit")); + s.edit(|d, _| { + // Imported parents can base themselves on another parent and carry local overrides. + let bp = Arc::make_mut(&mut d.parents[1]); + bp.parent.as_mut().unwrap().based_on = Some(a); + bp.pages[0].parent = Some(a); + bp.pages[0].overridden.push(text); + Arc::make_mut(&mut d.parents[2]).parent.as_mut().unwrap().based_on = Some(b); + d.item_mut(inherited).unwrap().overrides = Some(text); + Ok(()) + }) + .unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap(); + let other_local = ItemId(s.execute("layout.overrideParentItems", &json!({"page": 1})).unwrap()["ids"][0].as_u64().unwrap()); + s.execute("layout.parents.edit", &json!({"on": true})).unwrap(); + s.doc_mut().unwrap().selection = Selection::text(TextSel::caret(story, 0)); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + before.check().unwrap(); + + s.execute("layout.parents.delete", &json!({"id": a.0})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), history + 1); + assert_eq!(st.selection, Selection::default()); + assert!(st.editing_parents, "other parents remain editable"); + let d = &st.doc; + assert_eq!(d.parents.iter().map(|p| p.id).collect::>(), [b, c]); + assert_eq!(d.page(0).unwrap().parent, None); + assert_eq!(d.page(1).unwrap().parent, Some(b)); + assert_eq!(d.page(2).unwrap().parent, None); + assert_eq!(d.parents[0].parent.as_ref().unwrap().based_on, None); + assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(b)); + assert_eq!(d.parents[0].pages[0].parent, None); + assert!(d.parents[0].pages[0].overridden.is_empty()); + assert!([group, text, shape].iter().all(|id| d.item(*id).is_none())); + assert!(d.story(story).is_none(), "nested text frames leave no dangling story"); + assert_eq!(d.item(local).unwrap().overrides, None); + assert_eq!(d.story(local_story).unwrap().text, "Parent folio"); + assert!(d.page(0).unwrap().overridden.is_empty()); + assert_eq!(d.item(inherited).unwrap().overrides, None); + assert_eq!(d.item(other_local).unwrap().overrides, Some(inherited)); + assert_eq!(d.page(1).unwrap().overridden, [inherited]); + d.check().unwrap(); + let after = d.clone(); + + s.execute("edit.undo", &Value::Null).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.doc().unwrap().doc.check().unwrap(); + s.execute("edit.redo", &Value::Null).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn parent_delete_last_parent_returns_to_document_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + let id = s.doc().unwrap().doc.parents[0].id; + s.execute("layout.parents.edit", &json!({"on": true})).unwrap(); + frame(&mut s, 0, Some("Last parent")); + s.execute("layout.parents.delete", &json!({"id": id.0})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.parents.is_empty()); + assert!(!st.editing_parents); + assert_eq!(st.doc.page_count(), 2); + assert!(st.doc.spreads.iter().flat_map(|sp| &sp.pages).all(|page| page.parent.is_none())); + s.execute("edit.undo", &Value::Null).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].id, id); + assert_eq!(s.doc().unwrap().selection, Selection::default()); + s.execute("edit.redo", &Value::Null).unwrap(); + assert!(s.doc().unwrap().doc.parents.is_empty()); + assert!(!s.doc().unwrap().editing_parents); + // A parent-free document still supports creating another parent. + s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents.len(), 1); + s.doc().unwrap().doc.check().unwrap(); +} diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 2d6d56da2..c3d60b07b 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -81,12 +81,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let mut drop: Option<(PageDrag, usize)> = None; ui.spacing_mut().item_spacing.y = 0.0; // ---- Parents: [None] and each parent, 29 pt rows with a blank thumbnail at the right. - let mut rows: Vec<(String, Option, usize)> = vec![("[None]".into(), None, 1)]; + let mut rows = vec![("[None]".to_string(), None, 1, None)]; for p in &doc.parents { let label = p.parent.as_ref().map(|i| i.label()).unwrap_or_default(); - rows.push((label, p.parent.as_ref().map(|i| i.prefix.clone()), p.pages.len())); + rows.push((label, p.parent.as_ref().map(|i| i.prefix.clone()), p.pages.len(), Some(p.id))); } - for (label, prefix, n) in rows { + for (label, prefix, n, id) in rows { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); let active = editing_parents && prefix.is_some(); if active { @@ -104,6 +104,17 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if resp.dragged() { drag_ghost(ui, &label, &t); } + if let Some(id) = id { + resp.context_menu(|ui| { + let label = crate::i18n::tr(&app.ui.language, "Delete Parent"); + if ui.button(crate::rtl::widget(ui, label)).clicked() { + if app.run("layout.parents.delete", json!({"id": id.0})).is_ok() { + app.views.clear(); + } + ui.close(); + } + }); + } } ui.add_space(4.0); let r = ui.available_rect_before_wrap(); @@ -424,6 +435,31 @@ mod tests { h } + #[test] + fn parent_context_menu_deletes_the_clicked_id_even_with_a_shared_prefix() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + let original = app.session.doc().unwrap().doc.parents[0].id; + let added = app.run("layout.parents.new", json!({"prefix": "A", "name": "Other"})).unwrap()["id"].as_u64().unwrap(); + let mut h = harness(app); + // [None], A-Parent, then A-Other: target the actual row, not a menu helper. + let at = egui::pos2(40.0, ROW_H * 2.5); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + h.get_by_label("Delete Parent").click(); + h.run_steps(3); + let doc = &h.state().session.doc().unwrap().doc; + assert_eq!(doc.parents.len(), 1); + assert_eq!(doc.parents[0].id, original); + assert!(doc.parents.iter().all(|p| p.id.0 != added)); + assert_eq!(doc.page_count(), 2, "document pages are not deleted"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.doc().unwrap().doc.parents.len(), 2); + } + #[test] fn a_page_in_a_spread_offers_delete_page_and_delete_spread() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/agents.md b/docs/agents.md index b737ebb81..1930055ce 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -74,3 +74,12 @@ designcraft-cli app --method ui.screenshot '{"path": "/tmp/w.png"}' # any contro `ui.menu.invoke {command}` without params acts like choosing the menu item: a command labelled "…" opens its dialog, which `ui.dialog.set` / `ui.dialog.confirm` fill in and close like a user would. + +## Parent pages + +In the Pages panel, right-click a parent row and choose **Delete Parent**. The equivalent +command is `layout.parents.delete {id}`, using the parent spread's numeric ID. It deletes that +parent's shared artwork and clears its assignments from document pages and other parents. +Local override copies remain as ordinary editable objects; document pages and their own content +remain. Undo restores the parent and its relationships in one step. Deleting the last parent +returns the canvas to document pages. Parent navigation is a separate operation. From d78783b6bd47e72e5167e977fa34b23498f64bdf Mon Sep 17 00:00:00 2001 From: Abdelrehman Fouad <97409061+MaridWSH@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:15:47 +0200 Subject: [PATCH 07/41] =?UTF-8?q?Fonts:=20Type=20=E2=80=BA=20Reload=20Font?= =?UTF-8?q?s=20reads=20fonts=20changed=20on=20disk=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fonts installed, edited or removed while the app runs needed a restart (#328, #547). font.reload rescans the font folders, lets go of the shared faces whose installed file changed (length or modification time) or is gone, so the next lookup reads the file as it is now, and recomposes the open documents with a new composition cache. It isn't an undo step. --- ROADMAP.md | 1 + crates/engine/src/cmd/fonts.rs | 42 +++++++++++++++- crates/fonts/src/fontdb.rs | 78 +++++++++++++++++++++++++++++- crates/fonts/src/lib.rs | 2 +- crates/fonts/src/tests_sysfonts.rs | 50 +++++++++++++++++++ crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menus.rs | 2 + docs/gaps.md | 5 +- docs/target-app-parity.md | 2 +- docs/typography-parity.md | 4 +- 11 files changed, 178 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..0e4b8eb94 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-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** · 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/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 55bf4231b..b1577400c 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -1,5 +1,6 @@ //! Type › Find/Replace Font: the fonts a document uses (missing ones flagged) and replacing one -//! font with another everywhere — local formatting and style definitions. +//! font with another everywhere — local formatting and style definitions. Type › Reload Fonts: +//! the fonts installed, edited or removed since they were read. use std::collections::BTreeMap; use std::sync::Arc; @@ -8,7 +9,7 @@ use designcraft_doc::{CharAttrs, Document, Story}; use designcraft_fonts::FontSource; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use super::{CommandSpec, always, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -25,9 +26,30 @@ pub fn specs() -> Vec { has_doc, replace ), + cmd!( + noundo "font.reload", + "Reload Fonts", + ["Type"], + None, + "{} — scans the font folders again and reads again the installed font files changed or removed since they were read, then recomposes the open documents → {cataloged, changed: [path]}", + always, + reload + ), ] } +/// Fonts installed, edited or removed while the app runs (#328, #547): the font database reads +/// them again, then every open document is composed again with what is installed now. +fn reload(s: &mut Session, _: &Value) -> Result { + let reloaded = designcraft_fonts::FontDb::global().reload_fonts(); + // A new cache: render jobs still holding the old one can't bring back the old fonts' layout. + s.cache = Arc::new(designcraft_compose::Cache::new()); + for st in &mut s.docs { + st.bump_revision(); + } + Ok(json!({"cataloged": reloaded.cataloged, "changed": reloaded.changed})) +} + /// Every story's text, including table cells and footnotes. pub(super) fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) { let mut pending: Vec<&Story> = d.stories.values().map(AsRef::as_ref).collect(); @@ -233,6 +255,22 @@ fn replace(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + #[test] + fn reload_fonts_recomposes_open_documents_without_editing_them() { + let mut s = Session::new(); + let r = s.execute("font.reload", &json!({})).unwrap(); + assert!(r["cataloged"].is_u64() && r["changed"].is_array(), "{r}"); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Some text here."})).unwrap(); + let (revision, undo) = (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len()); + let cache = Arc::clone(&s.cache); + s.execute("font.reload", &json!({})).unwrap(); + assert!(!Arc::ptr_eq(&cache, &s.cache), "the layout composed with the old fonts is let go"); + let st = s.doc().unwrap(); + assert_ne!(st.revision, revision, "the canvas draws the document again"); + assert_eq!(st.history.undo.len(), undo, "reloading fonts isn't an edit"); + } + #[test] fn replacement_resolves_inherited_family_and_style_and_rejects_missing_targets() { let mut s = Session::new(); diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index f65dd700e..352e0952b 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -370,6 +370,24 @@ impl FontFace { } } +/// A font file's length and modification time. +#[cfg(not(target_arch = "wasm32"))] +type FileStamp = (u64, Option); + +#[cfg(not(target_arch = "wasm32"))] +fn file_stamp(path: &std::path::Path) -> Option { + std::fs::metadata(path).ok().map(|m| (m.len(), m.modified().ok())) +} + +/// What [`FontDb::reload_fonts`] did: the faces cataloged by the rescan, and the installed font +/// files that changed or were removed since they were read (their faces are read again, from the +/// new file, the next time they are looked up). +#[derive(Debug, Default, PartialEq)] +pub struct ReloadedFonts { + pub cataloged: usize, + pub changed: Vec, +} + #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Debug)] struct CatalogEntry { @@ -412,6 +430,10 @@ pub struct FontDb { outlines: Mutex>>, #[cfg(not(target_arch = "wasm32"))] catalog: RwLock>, + /// Each installed font file read into the shared fonts, with its (length, modification time) + /// when it was read: [`FontDb::reload_fonts`] reads again the files that changed since. + #[cfg(not(target_arch = "wasm32"))] + installed_files: Mutex>, /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] font_dirs: Vec, @@ -1067,6 +1089,8 @@ impl FontDb { outlines: Mutex::new(HashMap::new()), #[cfg(not(target_arch = "wasm32"))] catalog: RwLock::new(Vec::new()), + #[cfg(not(target_arch = "wasm32"))] + installed_files: Mutex::new(HashMap::new()), font_dirs, font_files: Vec::new, #[cfg(not(target_arch = "wasm32"))] @@ -1221,6 +1245,56 @@ impl FontDb { count } + /// Fonts installed, changed or removed since they were read (#328): scan the font folders + /// again (see [`FontDb::load_system_fonts`]), then let go of the shared faces whose installed + /// file changed (length or modification time) or is gone, so the next lookup reads the file as + /// it is now, or finds the font missing. What was composed with the old faces must be composed + /// again. Faces are never freed (see [`FaceRef`]): each reload of a changed file keeps the old + /// copy. Bundled, added and document fonts are left alone. Nothing to do on wasm. + pub fn reload_fonts(&self) -> ReloadedFonts { + #[cfg(not(target_arch = "wasm32"))] + { + // Rescan first: a lookup after the faces are dropped finds the new files. + let cataloged = self.load_system_fonts(); + let changed: Vec = { + let mut files = self.installed_files.lock().unwrap_or_else(|e| e.into_inner()); + let mut changed: Vec<_> = files.iter().filter(|(p, stamp)| file_stamp(p).as_ref() != Some(*stamp)).map(|(p, _)| p.clone()).collect(); + changed.sort(); + for p in &changed { + files.remove(p); + } + changed + }; + if !changed.is_empty() { + let stale = |f: &FontFace| matches!(&f.source, FontSource::Installed(p) if changed.contains(p)); + let mut dropped = std::collections::HashSet::new(); + self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| { + let keep = !stale(f); + if !keep { + dropped.insert(f.id); + } + keep + }); + self.instances.write().unwrap_or_else(|e| e.into_inner()).retain(|(base, _), f| !dropped.contains(base) && !stale(f)); + self.outlines.lock().unwrap_or_else(|e| e.into_inner()).retain(|(face, _), _| !dropped.contains(face)); + log::debug!("reloading {} changed font files ({} faces)", changed.len(), dropped.len()); + } + ReloadedFonts { cataloged, changed } + } + #[cfg(target_arch = "wasm32")] + ReloadedFonts::default() + } + + /// Read an installed font file, noting its length and modification time as they were before + /// the read (a change during it is then found by the next reload). + #[cfg(not(target_arch = "wasm32"))] + fn read_installed(&self, path: &std::path::Path) -> Option> { + let stamp = file_stamp(path)?; + let data = std::fs::read(path).ok()?; + self.installed_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), stamp); + Some(data) + } + #[cfg(not(target_arch = "wasm32"))] fn scan_font_dirs(&self) -> usize { let mut found = Vec::new(); @@ -1294,7 +1368,7 @@ impl FontDb { // The whole family arrives at once (see `insert_faces`). let mut faces = Vec::new(); for p in paths { - if let Ok(data) = std::fs::read(&p) { + if let Some(data) = self.read_installed(&p) { faces.extend(self.new_faces(data, FontSource::Installed(p))); } } @@ -1499,7 +1573,7 @@ impl FontDb { if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) { continue; } - let Ok(data) = std::fs::read(&p) else { continue }; + let Some(data) = self.read_installed(&p) else { continue }; let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())); if hit && self.add_faces(data, FontSource::Installed(p)) > 0 && covered(self) { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 861e846a8..826beb0a2 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -20,7 +20,7 @@ mod vertical; pub use features::{FontFeatures, font_features}; pub use fontdb::{ DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES, - MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, same_style, system_font_dirs, + MAX_DOCUMENT_FONTS_TOTAL, ReloadedFonts, ScopedFonts, base_style, bundled, same_style, system_font_dirs, }; pub use group::{FamilyInfo, FontGroup, sort_for_menu}; pub use harfrust::Feature; diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index bf01ad79a..39bf0767e 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -389,3 +389,53 @@ fn installed_and_loaded_fonts_are_found_by_postscript_name() { assert_eq!(db.postscript_face("Source Serif 4"), None); assert_eq!(db.postscript_face("NoSuchFont-Regular"), None); } + +/// Write `font` to `path` with a modification time `secs` from now, so a rewrite of the same +/// length still reads as changed however coarse the file system's clock. +fn write_font(path: &Path, font: &[u8], secs: u64) { + std::fs::write(path, font).unwrap(); + let file = std::fs::File::options().write(true).open(path).unwrap(); + file.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(secs)).unwrap(); +} + +#[test] +fn reloading_reads_changed_font_files_again_and_keeps_the_others() { + use crate::testing::font_with_glyph; + let dir = font_dir("reload"); + let family = "DC Test Reloaded"; + let path = dir.join("Reloaded.ttf"); + write_font(&path, &font_with_glyph(family, &['a'], 'W').unwrap(), 10); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + let before = db.face(family, "Regular"); + let wide = before.advance(before.glyph_for('a')); + let kept = db.face(FAMILY, "Regular"); + // The font is edited and installed again under the same name (#328). + write_font(&path, &font_with_glyph(family, &['a'], 'i').unwrap(), 20); + assert_eq!(db.face(family, "Regular").id(), before.id(), "a lookup alone doesn't read the file again"); + let reloaded = db.reload_fonts(); + assert_eq!(reloaded.changed, std::slice::from_ref(&path)); + assert_eq!(reloaded.cataloged, 3); + let after = db.face(family, "Regular"); + assert_ne!(after.id(), before.id()); + let narrow = after.advance(after.glyph_for('a')); + assert!(narrow < wide, "the edited font sets 'a' as its i: {narrow} vs {wide}"); + assert_eq!(db.face(FAMILY, "Regular").id(), kept.id(), "unchanged files aren't read again"); + assert_eq!(db.reload_fonts().changed, Vec::::new(), "nothing changed since"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn reloading_lets_go_of_removed_fonts_and_finds_new_ones() { + let dir = font_dir("reload-removed"); + let removed = dir.join("Removed.ttf"); + write_font(&removed, &crate::testing::font_with("DC Test Removed", &['a']).unwrap(), 10); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + assert_eq!(db.face("DC Test Removed", "Regular").family, "DC Test Removed"); + std::fs::remove_file(&removed).unwrap(); + write_font(&dir.join("Added.ttf"), &crate::testing::font_with("DC Test Added Later", &['a']).unwrap(), 10); + assert_eq!(db.reload_fonts().changed, [removed]); + assert!(!db.has_family("DC Test Removed")); + assert_eq!(db.face("DC Test Removed", "Regular").family, FALLBACK_FAMILY); + assert!(db.has_family("DC Test Added Later")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 975da9ca1..d938c2dc7 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -580,6 +580,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Flattener Preview", "გაბრტყელების წინასწარი ნახვა"), ("Float Panel", "მცურავი პანელი"), ("Fonts in Document", "დოკუმენტის შრიფტები"), + ("Reload Fonts", "შრიფტების ხელახლა ჩატვირთვა"), ("Footnotes", "სქოლიოები"), ("Form Fields", "ფორმის ველები"), ("Frame Fitting Options…", "ჩარჩოს ზომაზე მორგების პარამეტრები…"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79eb2c44c..d993e9ed1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -834,7 +834,7 @@ impl DesignApp { } } if r.is_ok() - && matches!(id, "font.replace" | "edit.undo" | "edit.redo") + && matches!(id, "font.replace" | "font.reload" | "edit.undo" | "edit.redo") && let Some(dialog) = self.ui.dialog.as_mut().filter(|d| d.id == "findFont") { dialog.fields.remove("_fonts"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index acb8e3e42..79b8b2c4e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -461,6 +461,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:type.createOutlines", "ui:app.findFontDialog", + "cmd:font.reload", ">Change Case", "cmd:type.changeCase|UPPERCASE|{\"case\": \"upper\"}", "cmd:type.changeCase|lowercase|{\"case\": \"lower\"}", @@ -5004,6 +5005,7 @@ mod tests { "-", "Create Outlines", "Find/Replace Font…", + "Reload Fonts", "Change Case >", "-", "Type on a Path >", diff --git a/docs/gaps.md b/docs/gaps.md index 2e9c57b39..d328cd4c3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -93,7 +93,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 6. Installed fonts not always found (F) - **Missing:** fonts in user font folders used for text (#161, #110, #107), fonts that font - services and managers activate on Windows, reload when fonts change on disk (#328). + services and managers activate on Windows, noticing by itself when fonts change on disk (#328: + Type › Reload Fonts, `font.reload`, reads them again on request since 2026-10-16). - **Evidence:** on macOS the scan reads the font files CoreText has available, so fonts that Adobe Fonts and font managers activate are listed (#261, #327, 2026-10-11); not yet confirmed by the reporters. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c6991edd..dc04a00dd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -286,7 +286,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked. -- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. +- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself. - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). - Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement through craft-libs/craft-atomic, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original, and already-open readers retain the old bytes. On Unix a new file gets the permissions ordinary file creation gives it (the umask applies) and a replaced file keeps its permissions. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index b97ec6512..68d7a5d9b 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -1,6 +1,6 @@ # Typography and composition parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Type › Reload Fonts reads fonts installed, edited or removed since start-up (#328, #547); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs; anchored objects wrap text; Tabs panel listed as present (#343); character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6) Typesetting is what InDesign is bought for. A feature can exist and still set text differently from InDesign: different line breaks, different page count, overset where InDesign fits. This @@ -67,7 +67,7 @@ Fidelity: **unmeasured** unless stated. | CJK composition | mojikumi, kinsoku, aki, tsume, ruby, kenten, warichu, frame grid | most; mojikumi tables kept but not applied; ruby group-only; no frame grid | ~ | cjk.md, cjk-typography.md | | Arabic / Hebrew | kashida, digits, diacritic positioning, RTL stories and tables | ✓ | ~ | vendor justification presets unsupported; list bullets and multi-column RTL (#100) | | Missing glyphs and fonts | pink highlight, substitution | ✓ | ✓ | | -| Installed fonts | every activated font | system font folders; on macOS also fonts that font managers and Adobe Fonts activate; user folders not always found | ~ | #161, #110, #107, #327, #261 | +| Installed fonts | every activated font, picked up as installed or changed | system font folders; on macOS also fonts that font managers and Adobe Fonts activate; user folders not always found; fonts installed, edited or removed while running found by Type › Reload Fonts, not automatically | ~ | #161, #110, #107, #327, #261, #328 | | Composition highlights | H&J, keeps, custom tracking/kerning, substituted fonts | ✓ | ✓ | | Measured from the table above (2026-10-10): 34 rows, 18 ✓, 16 ~, 0 ✗, scoring ✓ = 1 and ~ = 0.5: From 4b520066e3a7d502f46fc4c042b46fb21f00fa33 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:58:01 +0200 Subject: [PATCH 08/41] Remove empty pages autoflow adds when placing into the primary frame Fixes #1165 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/place_text.rs | 56 ++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 5343f54f4..ccdca38b9 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -236,7 +236,8 @@ fn same_rect(a: Rect, b: Rect) -> bool { /// on its page (when it sits in one), then one frame per column on each page it adds. With /// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the /// live area with the page's columns instead. Empty frames it made at the end of the thread are -/// removed again, except the first on a page. Returns pages added. +/// removed again, except the first on a page it didn't add; a page it added that holds only such +/// a frame goes with it. Returns pages added. /// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors /// name `cmd`, the command that autoflows. pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result { @@ -249,6 +250,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s let vertical = story_vertical || primary; let mut added = 0; let mut made = Vec::new(); + let mut new_pages = Vec::new(); let mut first = true; loop { let (overset, held, full) = fit(d, sid); @@ -278,7 +280,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.columns.count.max(1) as usize) }; let pages = want.div_ceil(per_page).clamp(1, max_pages - added); let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); - d.insert_pages(Some(page), pages, parent)?; + new_pages.extend(d.insert_pages(Some(page), pages, parent)?); for pg in page + 1..=page + pages { let (si, pi) = d.page_loc(pg).ok_or_else(|| bad(cmd, "page insert failed"))?; let columns = d.spreads[si].pages[pi].columns.clone(); @@ -297,10 +299,24 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s while let Some(&f) = made.last() { let frames = d.story(sid).map_or(&[][..], |s| s.frames.as_slice()); let [.., before, tail] = frames else { break }; - if *tail != f || cs.frame(f).is_some_and(|ft| !ft.range.is_empty()) || d.page_of_item(*before) != d.page_of_item(f) { + if *tail != f || cs.frame(f).is_some_and(|ft| !ft.range.is_empty()) { break; } - d.remove_item(f)?; + let page = d.page_of_item(f); + if d.page_of_item(*before) != page { + // The first frame on its page: the estimate added more pages than the text needs. + // Drop the page too when autoflow added it and the frame is all it holds. + let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break }; + let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg)); + if !alone { + break; + } + d.remove_item(f)?; + d.delete_pages(&[pg])?; + added = added.saturating_sub(1); + } else { + d.remove_item(f)?; + } made.pop(); } Ok(added) @@ -686,6 +702,38 @@ mod reflow_tests { s.execute("text.delete", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.page_count(), 1); } + + /// Placing text into the primary frame adds only the pages the text needs, also when a wrap + /// leaves page 1 little room or the text ends at a page boundary (#1165). + #[test] + fn smart_text_reflow_place_adds_no_empty_pages() { + let dir = std::env::temp_dir().join(format!("dc-reflow-place-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (n, wrap, want) in [(280, true, 5), (250, false, 3)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "primaryTextFrame": true})).unwrap(); + if wrap { + let id = s.execute("frame.create", &json!({"rect": [36, 36, 576, 600]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.textWrap", &json!({"ids": [id], "mode": "boundingBox"})).unwrap(); + } + let sid = s.doc().unwrap().doc.settings.primary_story.expect("primary story"); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + let path = dir.join(format!("words-{n}.txt")); + std::fs::write(&path, "Words keep coming and the page fills up quickly with them. ".repeat(n)).unwrap(); + s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.page_count(), want, "{n} lines"); + let frames = &d.story(sid).unwrap().frames; + assert_eq!(frames.len(), d.page_count(), "{n} lines"); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + assert!(cs.overset_at.is_none(), "{n} lines"); + for f in frames { + assert!(cs.frame(*f).is_some_and(|ft| !ft.range.is_empty()), "{n} lines: empty frame {f:?}"); + } + d.check().unwrap(); + } + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] From 81cc1404b8c7335d5838e5a694d1815bfdec7dbb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:22:58 +0200 Subject: [PATCH 09/41] Skip phonetic guide text when importing XLSX strings Found while testing crates/textimport/src/xlsx.rs:62 and :112 (shared strings are read with El::text, which includes children; inline strings use the same call, crates/textimport/src/docx.rs:37) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/xlsx.rs | 43 ++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index d07956998..3f20f3dee 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -35,11 +35,23 @@ fn first_sheet_target(wb: &str, rels: &str) -> Option { Some(if target.starts_with("xl/") { target } else { format!("xl/{target}") }) } +/// The text of a shared string (``) or inline string (``): its own `` and the `` of its +/// `` runs. Phonetic guides (``, ``) are the reading, not the cell text. +fn string_item(si: &El) -> String { + si.els() + .map(|e| match e.name.as_str() { + "t" => e.text(), + "r" => e.els().filter(|t| t.name == "t").map(El::text).collect(), + _ => String::new(), + }) + .collect() +} + fn value(c: &El, shared: &[String]) -> String { let v = || c.child("v").map(El::text).unwrap_or_default(); match c.attr("t") { Some("s") => v().trim().parse::().ok().and_then(|i| shared.get(i).cloned()).unwrap_or_default(), - Some("inlineStr") => c.child("is").map(El::text).unwrap_or_default(), + Some("inlineStr") => c.child("is").map(string_item).unwrap_or_default(), // A cached formula string is text even when it looks like a number. Some("str") => v(), Some("b") => { @@ -90,7 +102,7 @@ pub fn records(bytes: &[u8], sheet: Option<&str>) -> Result>, Im let shared: Vec = part(&mut zip, "xl/sharedStrings.xml")? .map(|x| parse(&x)) .transpose()? - .map(|sst| sst.els().filter(|e| e.name == "si").map(El::text).collect()) + .map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect()) .unwrap_or_default(); let mut cells: Vec<(usize, usize, String)> = Vec::new(); if let Some(data) = sheet_xml.child("sheetData") { @@ -198,7 +210,7 @@ pub fn import(bytes: &[u8]) -> Result { let shared: Vec = part(&mut zip, "xl/sharedStrings.xml")? .map(|x| parse(&x)) .transpose()? - .map(|sst| sst.els().filter(|e| e.name == "si").map(El::text).collect()) + .map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect()) .unwrap_or_default(); let mut cells: Vec<((usize, usize), String)> = Vec::new(); if let Some(data) = sheet.child("sheetData") { @@ -355,6 +367,31 @@ mod tests { assert!(records(&book, Some("Missing")).is_err()); } + #[test] + fn phonetic_guide_text_is_not_cell_text() { + let book = sheets(&[ + ( + "xl/workbook.xml", + r#""#, + ), + ("xl/_rels/workbook.xml.rels", r#""#), + ( + "xl/sharedStrings.xml", + r#"日本ニホン東京トウキョウ"#, + ), + ( + "xl/worksheets/sheet1.xml", + r#"01大阪オオサカ"#, + ), + ]); + let want = ["日本", "東京", "大阪"].map(String::from).to_vec(); + assert_eq!(records(&book, None).unwrap(), vec![want.clone()]); + let imp = import(&book).unwrap(); + let t = imp.story.tables.values().next().expect("a table"); + let got: Vec = (0..3).map(|c| t.cell(0, c).unwrap().text.text.clone()).collect(); + assert_eq!(got, want); + } + fn one_value(cell: &str) -> Vec { workbook_with(&format!( r#"Value{cell}"# From 61387304b66fca571d16f40de32c89c32db1cf06 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:26:08 +0200 Subject: [PATCH 10/41] Ignore non-finite values in Tagged Text import Found while testing crates/textimport/src/tagged.rs:173 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/tagged.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index b84577eae..9bc84b766 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -170,7 +170,7 @@ pub fn import(bytes: &[u8]) -> Result { } "cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()), "cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| value.to_string()), - "cSize" => fmt.over.size = value.trim().parse().ok(), + "cSize" => fmt.over.size = value.trim().parse().ok().filter(|s: &f64| s.is_finite()), "DefineParaStyle" | "DefineCharStyle" => { let nm = value.split('=').next().unwrap_or("").trim().to_string(); let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles }; @@ -245,6 +245,17 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + /// `f64::from_str` accepts "NaN" and "inf"; a non-finite size must not reach the story. + #[test] + fn non_finite_char_size_is_ignored() { + for v in ["NaN", "inf", "infinity", "-inf"] { + let src = format!("\r\nbig"); + let i = import(src.as_bytes()).unwrap(); + let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size; + assert!(size.is_none_or(f64::is_finite), "{v}: size {size:?}"); + } + } + #[test] fn tagged_text_keeps_a_leading_empty_paragraph() { let doc = Document::new(&designcraft_doc::build::NewDocument::default()); From 3823d7d9420b74c661578db75c0ff2168ef4190d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:22:17 +0000 Subject: [PATCH 11/41] Ignore out-of-range font sizes in RTF import Found while testing crates/textimport/src/rtf.rs:219 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/rtf.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index f336b7f36..baf065c30 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -216,7 +216,10 @@ pub fn import(bytes: &[u8]) -> Result { "ul" => restyle(&|s| s.underline = on), "ulnone" => restyle(&|s| s.underline = false), "strike" => restyle(&|s| s.strike = on), - "fs" => restyle(&|s| s.size = param.map(|p| p as f64 / 2.0)), + // Half-points; sizes outside the engine's 0.1–1296 pt range fall back to the default. + "fs" => restyle(&|s| { + s.size = param.map(|p| p as f64 / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)); + }), "plain" => restyle(&|s| { s.bold = false; s.italic = false; @@ -297,6 +300,18 @@ Caf\'e9 \u8364? price\tab tab\line next\par assert!(!st.text.contains("Times"), "font table skipped"); } + /// `\fs` sizes outside the engine's 0.1–1296 pt character size range are not stored. + #[test] + fn font_size_out_of_range_is_not_stored() { + let i = import(br"{\rtf1\ansi\fs0 A\fs-8 B\fs999999999 C\fs24 D}").unwrap(); + let st = &i.story; + for ch in ["A", "B", "C"] { + let size = st.format_after(st.text.find(ch).unwrap()).over.size; + assert!(size.is_none_or(|s| (0.1..=1296.0).contains(&s)), "{ch}: {size:?}"); + } + assert_eq!(st.format_after(st.text.find('D').unwrap()).over.size, Some(12.0)); + } + /// A '-' right after a control word is text unless a digit follows it (then it is a negative /// parameter). It used to be swallowed. #[test] From 2cc21e19b2a0ea6ac48b0ca4f0fa098ccd5fd727 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:55:36 +0200 Subject: [PATCH 12/41] Ignore non-finite w:sz, w:spacing and w:ind values in DOCX import Found while testing crates/textimport/src/docx.rs:183 (run_attrs, w:sz) and :206-214 (para_attrs, w:spacing/w:ind via the `tw` closure) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/docx.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index c4375669f..783b010c6 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -180,7 +180,7 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs { if let Some(s) = r.on("strike") { a.strikethrough = Some(s); } - if let Some(sz) = r.val("sz").and_then(|v| v.parse::().ok()) { + if let Some(sz) = r.val("sz").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) { a.size = Some(sz / 2.0); } if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) { @@ -203,7 +203,7 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs { fn para_attrs(ppr: Option<&El>) -> ParaAttrs { let mut a = ParaAttrs::default(); let Some(p) = ppr else { return a }; - let tw = |v: Option<&str>| v.and_then(|v| v.parse::().ok()).map(|v| v / 20.0); + let tw = |v: Option<&str>| v.and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0); if let Some(sp) = p.child("spacing") { a.space_before = tw(sp.attr("before")); a.space_after = tw(sp.attr("after")); @@ -680,6 +680,28 @@ mod tests { assert_eq!(st.text, "Date: 1 May 2026.\nLink"); } + /// `str::parse::` accepts "NaN"/"inf"; sizes, spacing and indents must ignore them. + #[test] + fn non_finite_sizes_spacing_and_indents_are_ignored() { + let document = format!( + r#" + + ab + c + "# + ); + let st = import(&docx(&document, "", "")).unwrap().story; + let fin = |v: Option| v.is_none_or(f64::is_finite); + for p in &st.paras { + let p = &p.para; + assert!(fin(p.space_before) && fin(p.space_after), "{p:?}"); + assert!(fin(p.left_indent) && fin(p.right_indent) && fin(p.first_line_indent), "{p:?}"); + } + for at in [0, 1] { + assert!(fin(st.format_after(at).over.size), "{:?}", st.format_after(at).over.size); + } + } + #[test] fn tracked_changes_import_as_accepted_including_moves() { // Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import. From ec4fbb270fe7555a55c06141a8139fb89fae81bf Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:20:21 +0200 Subject: [PATCH 13/41] Ignore NaN and infinite numbers when importing IDML Found while testing crates/idml/src/import.rs:2748 (stroke_from: StrokeWeight, MiterLimit), also El::num in crates/idml/src/xml.rs:96 and the plain `.parse().ok()` calls around import.rs:1833, 2823 and 2832 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_idml.rs | 97 +++++++++++++++++++++++++++++++++ crates/idml/src/import.rs | 35 ++++++++---- crates/idml/src/names.rs | 2 +- crates/idml/src/xml.rs | 11 +++- 4 files changed, 133 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 0701052d0..3a49168be 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -575,3 +575,100 @@ fn table_of_contents_character_styles_and_sort_round_trip_through_idml() { let e = &after.entries[1]; assert_eq!((e.page_number_style.as_deref(), e.separator_style.as_deref(), e.sort), (None, None, false)); } + +/// `text` with the value after every `open` (up to the next `close`) set to `value`, and how +/// many values were replaced. +fn replace_values(text: &str, open: &str, close: char, value: &str) -> (String, usize) { + let mut rest = text; + let mut new = String::new(); + let mut count = 0; + while let Some(at) = rest.find(open) { + let start = at + open.len(); + new.push_str(&rest[..start]); + new.push_str(value); + let end = rest[start..].find(close).map_or(rest.len(), |e| start + e); + rest = &rest[end..]; + count += 1; + } + new.push_str(rest); + (new, count) +} + +/// `package` with every `{name}="…"` attribute and `<{name}>…` / `<{name} type="…">…` +/// property element in its XML parts set to `value`, and how many values were replaced. +fn with_attr_set(package: &[u8], name: &str, value: &str) -> (Vec, usize) { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(package)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let mut count = 0; + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + let mut body = Vec::new(); + std::io::Read::read_to_end(&mut f, &mut body).unwrap(); + if f.name().ends_with(".xml") + && let Ok(text) = String::from_utf8(body.clone()) + { + let (text, a) = replace_values(&text, &format!(" {name}=\""), '"', value); + let (text, b) = replace_values(&text, &format!("<{name}>"), '<', value); + let (text, c) = replace_values(&text, &format!("<{name} type=\"unit\">"), '<', value); + let (text, d) = replace_values(&text, &format!("<{name} type=\"double\">"), '<', value); + count += a + b + c + d; + body = text.into_bytes(); + } + out.start_file(f.name(), zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut out, &body).unwrap(); + } + (out.finish().unwrap().into_inner(), count) +} + +/// Non-finite numbers in an IDML package never reach the model: the imported document saves +/// and reopens. Every listed name occurs in the exported sample, so each case really rewrites +/// values; names the sample lacks (feather `Width`, gradient start/length) go through `El::num`, +/// covered in `xml.rs`. +#[test] +fn idml_non_finite_numbers_import_to_a_document_that_reopens() { + let package = designcraft_idml::export_idml(&crate::sample::magazine()); + let attrs = [ + "StrokeWeight", + "MiterLimit", + "Angle", + "FirstBaselineOffset", + "MinimumFirstBaselineOffset", + "SpaceBefore", + "SpaceAfter", + "LeftIndent", + "RightIndent", + "FirstLineIndent", + "PointSize", + "Tracking", + "Leading", + "FillTint", + "StrokeTint", + "GapTint", + "Position", + "BaselineShift", + "Skew", + ]; + let mut failures = Vec::new(); + for attr in attrs { + for value in ["NaN", "inf", "-inf"] { + let (bytes, replaced) = with_attr_set(&package, attr, value); + if replaced == 0 { + failures.push(format!("{attr}: not present in the exported package")); + continue; + } + let doc = match designcraft_idml::import_idml(&bytes) { + Ok(doc) => doc, + Err(e) => { + failures.push(format!("{attr}={value}: import failed: {e}")); + continue; + } + }; + match designcraft_format::save(&doc).map(|saved| designcraft_format::load(&saved)) { + Ok(Ok(_)) => {} + Ok(Err(e)) => failures.push(format!("{attr}={value}: saved but load failed: {e}")), + Err(e) => failures.push(format!("{attr}={value}: save failed: {e}")), + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 5cd3746a9..ae4d9f89f 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -391,6 +391,12 @@ fn nums(s: &str) -> Vec { s.split_whitespace().filter_map(|v| v.parse().ok()).collect() } +/// Like [`nums`], but empty when any number is NaN or infinite. +fn finite_nums(s: &str) -> Vec { + let v = nums(s); + if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() } +} + /// The viewBox size of an SVG whose root has a viewBox but no width/height (percentages count as /// none): InDesign places such a file at one viewBox unit to the point. fn svg_viewbox_size(data: &[u8]) -> Option<(f64, f64)> { @@ -936,12 +942,16 @@ impl<'r> Importer<'r> { // Custom stroke styles. for e in top.iter().filter(|e| matches!(e.local(), "StripedStrokeStyle" | "DashedStrokeStyle" | "DottedStrokeStyle")) { let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue }; - let nums = |k: &str| e.get(k).unwrap_or("").split_whitespace().filter_map(|x| x.parse::().ok()).collect::>(); let kind = match e.local() { "StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes { - bands: nums("StripeArray").as_chunks::<2>().0.iter().map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0)).collect(), + bands: finite_nums(e.get("StripeArray").unwrap_or("")) + .as_chunks::<2>() + .0 + .iter() + .map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0)) + .collect(), }, - "DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums("DashArray") }, + "DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: finite_nums(e.get("DashArray").unwrap_or("")) }, _ => designcraft_doc::StrokeType::Dotted, }; let _ = id; @@ -1830,7 +1840,7 @@ impl<'r> Importer<'r> { for li in tl.find_all("ListItem") { let get = |k: &str| li.find(k).map(|x| x.text_content()); tabs.push(TabStop { - position: get("Position").and_then(|v| v.trim().parse().ok()).unwrap_or(0.0), + position: get("Position").and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).unwrap_or(0.0), align: names::tab_align_in(get("Alignment").unwrap_or_default().trim()), leader: get("Leader").unwrap_or_default(), align_on: get("AlignmentCharacter").filter(|c| c != ".").unwrap_or_default(), @@ -2189,8 +2199,8 @@ impl<'r> Importer<'r> { if r >= nr || c >= nc { continue; } - let rs = (ce.num("RowSpan").unwrap_or(1.0).max(1.0) as usize).min(nr - r); - let cs = (ce.num("ColumnSpan").unwrap_or(1.0).max(1.0) as usize).min(nc - c); + let rs = (span(ce, "RowSpan").max(1.0) as usize).min(nr - r); + let cs = (span(ce, "ColumnSpan").max(1.0) as usize).min(nc - c); let mut cell = t.cell(r, c).cloned().unwrap_or_default(); let cell_style = ce.get("AppliedCellStyle").map(|r| self.cell_style_ref(r)).unwrap_or_else(|| designcraft_doc::NO_CELL_STYLE.into()); if cell_style != designcraft_doc::NO_CELL_STYLE { @@ -2745,7 +2755,7 @@ impl<'r> Importer<'r> { if let Some(c) = color { s.swatch = self.swatch_ref(&c); } - if let Some(w) = g(self, "StrokeWeight").and_then(|v| v.parse().ok()) { + if let Some(w) = g(self, "StrokeWeight").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) { s.weight = w; } if let Some(t) = g(self, "StrokeTint").and_then(|v| v.parse().ok()) { @@ -2763,7 +2773,7 @@ impl<'r> Importer<'r> { if let Some(v) = g(self, "EndJoin") { s.join = names::join_in(&v); } - if let Some(v) = g(self, "MiterLimit").and_then(|v| v.parse().ok()) { + if let Some(v) = g(self, "MiterLimit").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) { s.miter_limit = v; } if let Some(v) = g(self, "LeftLineEnd") { @@ -2829,7 +2839,7 @@ impl<'r> Importer<'r> { { it.fill.gradient_angle = Some(a); } - let start: Option> = e.prop("GradientFillStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect()); + let start: Option> = e.prop("GradientFillStart").map(|s| finite_nums(&s)); if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength")) && len > 0.0 { @@ -2847,7 +2857,7 @@ impl<'r> Importer<'r> { { it.stroke.gradient_angle = Some(a); } - let start: Option> = e.prop("GradientStrokeStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect()); + let start: Option> = e.prop("GradientStrokeStart").map(|s| finite_nums(&s)); if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength")) && len > 0.0 { @@ -3301,6 +3311,11 @@ fn overlay(base: &mut El, top: &El, depth: usize) { } } +/// A cell span count; an infinite span is kept so it clamps to the table edge. +fn span(e: &El, k: &str) -> f64 { + e.prop(k).and_then(|v| v.trim().parse::().ok()).unwrap_or(1.0) +} + fn finite(v: Option) -> Option { v.filter(|v| v.is_finite()) } diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 70d02e7c0..569244470 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -263,7 +263,7 @@ pub fn leading_out(l: Leading) -> (String, &'static str) { } } pub fn leading_in(s: &str) -> Option { - if s == "Auto" { Some(Leading::Auto) } else { s.trim().parse().ok().map(Leading::Points) } + if s == "Auto" { Some(Leading::Auto) } else { s.trim().parse::().ok().filter(|v| v.is_finite()).map(Leading::Points) } } pub fn stroke_align_out(a: StrokeAlign) -> &'static str { diff --git a/crates/idml/src/xml.rs b/crates/idml/src/xml.rs index b3f1ef0d0..374e70c7f 100644 --- a/crates/idml/src/xml.rs +++ b/crates/idml/src/xml.rs @@ -93,8 +93,9 @@ impl El { pub fn prop_el(&self, k: &str) -> Option<&El> { self.find("Properties").and_then(|p| p.find(k)) } + /// A finite numeric property; `NaN` and infinities count as absent. pub fn num(&self, k: &str) -> Option { - self.prop(k).and_then(|v| v.trim().parse().ok()) + self.prop(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) } pub fn boolean(&self, k: &str) -> Option { match self.prop(k)?.trim() { @@ -337,4 +338,12 @@ mod tests { assert_eq!(e.num("Leading"), Some(14.0)); assert_eq!(e.prop("Missing"), None); } + + /// `NaN` and infinities parse as `f64` but are not usable numbers. + #[test] + fn non_finite_numbers_count_as_absent() { + let x = br#"-inf2.5"#; + let e = parse(x).unwrap(); + assert_eq!((e.num("W"), e.num("X"), e.num("Y"), e.num("Z")), (None, None, None, Some(2.5))); + } } From 3eb7b5085b18f6080db38dcd6e04833df6d29584 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 06:27:34 +0000 Subject: [PATCH 14/41] Tools: readouts measure in the document's ruler units Tool readouts (the Page tool's size while dragging, smart-guide distances, the resize readout) took their unit from the UI's own setting, which nothing changes from picas, so a document set to inches read in picas. The tool context now takes the active document's horizontal ruler unit, and the Page tool's W/H use the horizontal and vertical units, as the Control and Properties fields do. Test: tool_readouts_measure_in_the_documents_units (picas before). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/tools/src/nav.rs | 4 ++-- crates/ui-egui/src/lib.rs | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index f371f267a..f77c5d0bb 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -434,8 +434,8 @@ impl Tool for PageTool { p: d.cur, text: format!( "W: {}\nH: {}", - designcraft_geom::format_measure(pg.width, cx.unit), - designcraft_geom::format_measure(pg.height, cx.unit) + designcraft_geom::format_measure(pg.width, cx.doc.settings.horizontal_units), + designcraft_geom::format_measure(pg.height, cx.doc.settings.vertical_units) ), }); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fcb764c5a..706852067 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -737,7 +737,9 @@ impl DesignApp { ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), snap: self.ui.snap_view(), - unit: self.ui.units, + // Tool readouts (sizes, smart-guide distances) measure in the document's ruler units, + // as its Control and Properties fields do. + unit: self.session.active().map_or(self.ui.units, |st| st.doc.settings.horizontal_units), stroke_in_front: self.ui.stroke_front, } } @@ -1843,6 +1845,17 @@ mod tests { assert!(!accurate.rich_black, "a saved choice is kept"); } + /// Tool readouts measured in picas whatever the document's units (the UI's unit is never set): + /// they follow the active document's ruler units. + #[test] + fn tool_readouts_measure_in_the_documents_units() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + assert_eq!(app.view_info().unit, Unit::Picas, "no document: the UI's default"); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": "inches", "verticalUnits": "millimeters"})).unwrap(); + assert_eq!(app.view_info().unit, Unit::Inches); + } + /// An app with one document that has an unsaved edit. fn app_with_unsaved_document() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); From 485a964e94e24f846b6fefa3063750bd9694a20c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:51:07 +0000 Subject: [PATCH 15/41] Reject a non-finite or non-positive --scale in designcraft-cli run Found while testing apps/designcraft-cli/src/main.rs:361 (`--scale` parse) and :386 (export); crates/render/src/lib.rs:422-423 (render_page) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/designcraft-cli/src/main.rs | 5 ++++- apps/designcraft-cli/tests/cli.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 20e8509e7..51bf67f05 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -330,7 +330,10 @@ fn run(args: &[String]) -> Result<(), String> { } } "--page" => page = val()?.parse().map_err(|_| "bad --page")?, - "--scale" => scale = val()?.parse().map_err(|_| "bad --scale")?, + "--scale" => { + // 0, a negative value and NaN made a 1×1 image; infinity, "no such page". + scale = val()?.parse().ok().filter(|s: &f64| s.is_finite() && *s > 0.0).ok_or("bad --scale: expected a positive number")?; + } "--pdf-options" => { // The export adds its path to them. pdf_opts = match serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))? { diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 0cb9be859..173a605f4 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -252,6 +252,22 @@ fn run_export_options_come_before_their_export() { let _ = std::fs::remove_dir_all(&dir); } +/// `--scale 0`, a negative value or `nan` wrote a 1×1 PNG and exited 0; `inf` said "no such page". +#[test] +fn run_scale_that_is_not_finite_and_positive_is_an_error() { + let dir = std::env::temp_dir().join(format!("dc-cli-scale-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("a.png"); + for scale in ["nan", "0", "-2", "inf", "-inf"] { + let out = cli().args(["run", "--sample", "--scale", scale, "--export", png.to_str().unwrap()]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "--scale {scale}: {err}"); + assert!(err.contains("bad --scale"), "--scale {scale}: {err}"); + } + assert!(!png.exists(), "nothing is exported when the scale is wrong"); + let _ = std::fs::remove_dir_all(&dir); +} + /// `--pdf-options` must be a JSON object; any other JSON value used to panic when the PDF path was set on it. #[test] fn pdf_options_that_are_not_an_object_are_an_error_not_a_panic() { From 1b1a66ee7740f06101ccf4625d7de24ced693e8f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:33:22 +0200 Subject: [PATCH 16/41] Make the dock collapse and expand chevrons clickable (#1203) --- crates/ui-egui/src/dock.rs | 62 +++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index fb9a7cf17..544f00721 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -68,7 +68,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { egui::Panel::right("dock").default_size(253.0).size_range(220.0..=420.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel)).show( ui, |ui| { - dock_header(ui, &t, "»"); + if dock_header(ui, &t, "»").clicked() { + app.ui.dock_expanded = false; + } // Tab strip (27 pt). let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover()); ui.painter().rect_filled(strip, 0.0, t.tab_strip); @@ -127,7 +129,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // (Essentials Classic) show their names. let groups = app.ui.icon_groups.clone(); egui::Panel::right("icon_column").exact_size(icon_column_width(app)).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { - dock_header(ui, &t, "«"); + if dock_header(ui, &t, "«").clicked() { + app.ui.dock_expanded = true; + } ui.add_space(4.0); match &groups { Some(groups) => { @@ -194,12 +198,13 @@ fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egu crate::panel_docking::legacy_tab(app, ui, id, response); } -/// 11 pt dock header strip with a collapse chevron. -fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::hover()); +/// 11 pt dock header strip with a collapse chevron; clicking it reports a click on the response. +fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) -> egui::Response { + let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::click()); ui.painter().rect_filled(r, 0.0, t.tab_strip); ui.painter().text(egui::pos2(r.max.x - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, chevron, egui::FontId::proportional(9.0), t.text_dim); ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); + response } /// A panel opened from the icon column, shown as a floating flyout next to the dock. @@ -518,3 +523,50 @@ mod visual_regressions { } } } + +#[cfg(test)] +mod chevron_tests { + use super::*; + use egui_kittest::Harness; + + fn click(harness: &mut Harness<'_, DesignApp>, at: egui::Pos2) { + harness.hover_at(at); + harness.run_steps(1); + harness.drag_at(at); + harness.run_steps(1); + harness.drop_at(at); + harness.run_steps(3); + } + + /// The » / « chevron painted at the right end of a panel's 11 pt header. + fn chevron(harness: &Harness<'_, DesignApp>, panel: &str) -> egui::Pos2 { + let r = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(panel)).expect("the panel is laid out").outer_rect; + egui::pos2(r.max.x - 10.0, r.min.y + 5.0) + } + + /// Issue #1203: the header chevrons were painted text; clicking them did nothing. + #[test] + fn the_dock_chevron_collapses_and_expands_the_dock() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui.dock_expanded = true; + let mut ready = false; + let mut harness = Harness::builder().with_size(vec2(1280.0, 640.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), "en"); + ready = true; + return; + } + show(app, ui); + }, + app, + ); + harness.run_steps(4); + let at = chevron(&harness, "dock"); + click(&mut harness, at); + assert!(!harness.state().ui.dock_expanded, "» collapses the dock to icons"); + let at = chevron(&harness, "icon_column"); + click(&mut harness, at); + assert!(harness.state().ui.dock_expanded, "« expands the dock again"); + } +} From 77625733445b99760df3773faa8a48318479b6ea Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:39:38 +0000 Subject: [PATCH 17/41] Keep a moved equation in place when editing its LaTeX Found while testing crates/engine/src/cmd/mathexpr.rs:73-83 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/mathexpr.rs | 46 ++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index e0ec7a56b..b684f2457 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -61,7 +61,9 @@ fn insert(s: &mut Session, p: &Value) -> Result { if let Some(id) = super::id_param(p, "id") { return s.edit(|d, _| { let old = d.item(id).ok_or_else(|| bad("math.insert", "no such object"))?; - let at = old.bounds(); + // The path and graphic live in the item's inner space, which `xf` maps to the spread: + // build the new ones at the old inner corner and keep `xf`. + let at = old.path.bounds().ok_or_else(|| bad("math.insert", "equation has no geometry"))?; let it = math_item(d, &latex, size, lid, (at.x0, at.y0)); let target = d.item_mut(id).ok_or_else(|| bad("math.insert", "no such object"))?; target.path = it.path; @@ -129,4 +131,46 @@ mod tests { let st = &s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(f["story"].as_u64().unwrap())]; assert_eq!(st.objects.len(), 1); } + + /// The item's top-left corner in spread space (inner path corner through its transform). + fn origin(s: &Session, id: designcraft_doc::ItemId) -> designcraft_geom::Point { + let d = &s.doc().unwrap().doc; + let it = d.item(id).unwrap(); + let r = it.path.bounds().unwrap(); + it.xf * designcraft_geom::Point::new(r.x0, r.y0) + } + + #[test] + fn editing_a_transformed_equation_keeps_its_place() { + for step in ["move", "rotate", "scale"] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("math.insert", &json!({"latex": r"\frac{a}{b}", "x": 100, "y": 100})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + match step { + "move" => s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})), + "rotate" => s.execute("transform.rotate", &json!({"ids": [id.0], "angle": 30})), + _ => s.execute("transform.scale", &json!({"ids": [id.0], "sx": 2.0, "sy": 1.5})), + } + .unwrap(); + let before = origin(&s, id); + s.execute("math.insert", &json!({"id": id.0, "latex": r"\frac{a}{b}"})).unwrap(); + let after = origin(&s, id); + assert!((before.x - after.x).abs() < 1e-6 && (before.y - after.y).abs() < 1e-6, "{step}: {before:?} -> {after:?}"); + } + } + + #[test] + fn editing_a_moved_equation_keeps_its_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("math.insert", &json!({"latex": "x", "x": 100, "y": 100})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})).unwrap(); + let before = s.doc().unwrap().doc.item(id).unwrap().bounds(); + s.execute("math.insert", &json!({"id": id.0, "latex": "x"})).unwrap(); + let after = s.doc().unwrap().doc.item(id).unwrap().bounds(); + assert_eq!((before.x0, before.y0), (after.x0, after.y0)); + assert_eq!((after.x0, after.y0), (150.0, 140.0)); + } } From b13b1ec40860efe2487a0a34d22e945fb49c185b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:03:26 +0200 Subject: [PATCH 18/41] Keep the mapped style of the first paragraph when placing text at an empty caret (#1208) --- crates/engine/src/cmd/place_text.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 5343f54f4..a3b9e5ef0 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -366,7 +366,14 @@ pub(super) fn place_text( let story = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); story.delete(r.clone()); + // An insertion point in an empty paragraph takes the first imported paragraph's style + // (insert_story would keep the empty paragraph's own, losing the styleMap result). + let p0 = story.para_at(r.start); + let blank = story.para_ranges().get(p0).is_some_and(|x| x.is_empty()); let end = story.insert_story(r.start, &imp.story); + if blank && let (Some(first), Some(p)) = (imp.story.paras.first(), story.paras.get_mut(p0)) { + *p = ParaFormat { table: p.table, ..first.clone() }; + } sel.text = Some(TextSel { anchor: end, focus: end, ..t }); if link.is_some() { warnings.push("not linked: a linked text file fills a whole story, not an insertion point".into()); @@ -767,6 +774,20 @@ mod pdf_page_tests { assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().chars.size, Some(10.0)); } + #[test] + fn word_first_paragraph_into_empty_caret_keeps_mapped_style() { + // Regression (#1208): placing into a new text frame (which leaves its caret selected) left the first paragraph unstyled. + let b64 = crate::cmd::base64_encode(&word_file()); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Headline"})).unwrap(); + let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text"})).unwrap(); + let sid = fr["story"].as_u64().unwrap(); + s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "frame": fr["id"], "styleMap": {"Title": "Headline"}})).unwrap(); + let st = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + assert_eq!(st.paras.iter().map(|p| p.style.as_str()).collect::>(), ["Headline", "Body"]); + } + #[test] fn word_normal_comes_in_as_its_own_style() { // Regression (#722): Normal's formatting (over Word's document defaults) was dropped. From f633cf54ddc0e72ba1b6f964d482c9688bcad12a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:05:48 +0200 Subject: [PATCH 19/41] PDF/X-4 export: add xmp:MetadataDate and xmpMM:VersionID to the XMP (#1207) --- crates/pdf/src/export.rs | 2 +- crates/pdf/src/pdfx.rs | 25 ++++++++++++++++++++----- crates/pdf/src/tests.rs | 14 ++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 3afe22015..4e7627e9e 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -254,7 +254,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions } if opts.standard == Standard::PdfX4 { // Output intent and PDF/X identification, then check the result. - match crate::pdfx::make_pdfx4(&bytes, &title) { + match crate::pdfx::make_pdfx4(&bytes, &title, created) { Some(b) => bytes = b, None => warnings.push("PDF/X-4: the output intent couldn't be added".into()), } diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 75d661cf8..534886f1d 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -23,9 +23,11 @@ fn lit(s: &str) -> String { o } -/// Turn krilla's output into PDF/X-4. `None` when the file isn't the simple kind this -/// understands (a classic cross-reference table and plain dictionaries). -pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> { +/// Turn krilla's output into PDF/X-4. `date` (Unix seconds, UTC) is the XMP metadata date; `None` +/// (no clock, as on wasm) writes the epoch, because PDF/X-4 requires the entry. `None` back when +/// the file isn't the simple kind this understands (a classic cross-reference table and plain +/// dictionaries). +pub fn make_pdfx4(pdf: &[u8], title: &str, date: Option) -> Option> { let t_at = rfind(pdf, b"trailer")?; let trailer = String::from_utf8_lossy(&pdf[t_at..]).to_string(); let size = int_after(&trailer, "/Size")?; @@ -84,7 +86,8 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> { i } }; - // XMP: the PDF/X identification schema, and the trapping state that Info also states. + // XMP: the PDF/X identification schema, the trapping state that Info also states, and the + // metadata date and version ID that PDF/X-4 requires. if let Some(meta) = int_after(&cat, "/Metadata") { let r = object(pdf, meta)?; let body = &pdf[r]; @@ -96,11 +99,17 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> { if dict.contains("/Filter") { return None; } + let (y, mo, d, h, mi, se) = crate::export::civil(date.unwrap_or(0)); + let stamp = format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z"); let add = concat!( "PDF/X-4", "False" ); - let xml = xml.replacen("", &format!("{add}"), 1); + let dates = format!( + "\ + {stamp}1" + ); + let xml = xml.replacen("", &format!("{add}{dates}"), 1); let dict = { let i = dict.find("/Length")?; let rest = &dict[i + 7..]; @@ -216,6 +225,12 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec { if !text.contains("pdfxid:GTS_PDFXVersion>PDF/X-4<") { issues.push("the XMP metadata doesn't name PDF/X-4".into()); } + if !text.contains("") { + issues.push("the XMP metadata has no xmp:MetadataDate".into()); + } + if !text.contains("") { + issues.push("the XMP metadata has no xmpMM:VersionID".into()); + } if text.contains("/Interpolate true") { issues.push("an image asks for interpolation".into()); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index ed6a1ac1f..ee02e2190 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -163,6 +163,20 @@ fn spreads_and_ranges() { assert_eq!(export_pdf(&d, &Cache::new(), &PdfOptions { pages: Some(vec![9]), ..Default::default() }), Err(PdfError::BadPage(10))); } +#[test] +fn pdfx4_xmp_has_metadata_date_and_version_id() { + let d = doc_with_text("Print me"); + let opts = PdfOptions { standard: Standard::PdfX4, created: Some(1_700_000_000), ..Default::default() }; + let r = export_pdf_with_report(&d, &Cache::new(), &opts).unwrap(); + let text = String::from_utf8_lossy(&r.bytes).to_string(); + assert!(text.contains("2023-11-14T22:13:20Z"), "metadata date"); + assert!(text.contains("1"), "version id"); + assert!(crate::check_pdfx4(&r.bytes).is_empty()); + // Without the two entries the check says so. + let stripped = text.replace("xmp:MetadataDate", "xmp:Other").replace("xmpMM:VersionID", "xmpMM:Other"); + assert_eq!(crate::check_pdfx4(stripped.as_bytes()).iter().filter(|i| i.contains("xmp")).count(), 2); +} + #[test] fn standards() { let d = doc_with_text("Archive me"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c6991edd..3ebe08f37 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -377,7 +377,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel and Character Style Options; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default; IDML Kashidas): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left. - Interface language (Preferences ▸ General ▸ Interface Language, `app.language`; each language listed by its own name): German, French, Spanish, Italian, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage. Polish covers 1,717 catalog keys with the same category-first count captions and English fallback. - Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update. -- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with the working CMYK space's profile (by default craft-cms's Coated FOGRA39 profile, identifier FOGRA39; Generic CMYK and Device CMYK embed our Generic CMYK profile, lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result. +- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with the working CMYK space's profile (by default craft-cms's Coated FOGRA39 profile, identifier FOGRA39; Generic CMYK and Device CMYK embed our Generic CMYK profile, lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, `xmp:MetadataDate` and `xmpMM:VersionID` in XMP, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result. - EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted. - Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections. - Colour management exposed (ICC through the craft-libs `craft-cms` crate): Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, default CMYK Coated FOGRA39 with CMYK swatches shown relative colorimetric with black-point compensation, Generic CMYK and Device CMYK still selectable, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas. Placed images are shown through their embedded ICC profiles, and CMYK images through their own profile or the working CMYK space, with the settings' intent and black-point compensation. From 636277c69fb86047468025b4300f09f747576ce2 Mon Sep 17 00:00:00 2001 From: ghanashyam03 Date: Sat, 17 Oct 2026 20:35:40 +0530 Subject: [PATCH 20/41] docs: fix broken parity checklist link --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..75f2f01e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -186,7 +186,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope ## How far from full parity (estimate, 2026-10-04) **Breadth: ~99% weighted** (P0 core 99%, P1 100%, P2 97%) over the 275 features of the InDesign catalogue, scored -row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still +row by row in [docs/parity-checklist.md](docs/parity-checklist.md) (`cargo xtask parity` recomputes it). Many features scored done still lack some of InDesign's options or dialog details, so **overall parity including depth is about 83%**. **Remaining work: about 170 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 60–85 hours with From 08ce444054898810961d63732164b466d7812f8d Mon Sep 17 00:00:00 2001 From: Aykut Firat Date: Sat, 17 Oct 2026 15:07:46 -0400 Subject: [PATCH 21/41] =?UTF-8?q?Tables:=20cell=20First=20Baseline=20offse?= =?UTF-8?q?t=20(Cell=20Options=20=E2=80=BA=20Text=20=E2=80=BA=20First=20Ba?= =?UTF-8?q?seline)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cells and cell styles had no first-baseline setting, so every cell set its first line by the Ascent offset (the font's typographic ascender). IDML documents that set FirstBaselineOffset on a cell style (CapHeight is common, often through a table style's body region) composed every row taller than the document asked for. With a font whose typographic ascender is a full em (Avenir), each row grew about 2.6 pt and long tables overset, losing their last rows. - doc: Cell.first_baseline / first_baseline_min; CellStyle.first_baseline / first_baseline_min, applied by CellStyle::apply_to and carried through resolve_cell_style's BasedOn chain. - idml: FirstBaselineOffset and MinimumFirstBaselineOffset read and written on cells and cell styles. - compose: a cell's text frame uses the cell's first baseline offset and minimum. - engine: table.setCell, style.cell.create/edit take firstBaseline / firstBaselineMin; table.get reports them; style.cell.create fromSelection captures them. - tests: import through BasedOn and a table style's body region, a cell's own override, IDML round trip; composer cap height vs ascent and a fixed minimum; command parameters and validation. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/table.rs | 19 ++++++++++- crates/doc/src/styles.rs | 17 ++++++++++ crates/doc/src/table.rs | 16 ++++++++- crates/engine/src/cmd/table.rs | 60 ++++++++++++++++++++++++++++++++-- crates/idml/src/export.rs | 8 +++++ crates/idml/src/import.rs | 8 +++++ crates/idml/src/tests.rs | 41 +++++++++++++++++++++++ 7 files changed, 164 insertions(+), 5 deletions(-) diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index d70ff074a..940e0aa77 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -125,7 +125,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left let spec = FrameSpec { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), - opts: TextFrameOptions::default(), + opts: TextFrameOptions { first_baseline: cell.first_baseline, first_baseline_min: cell.first_baseline_min, ..TextFrameOptions::default() }, vertical: false, exclusions: vec![], outline: None, @@ -835,6 +835,23 @@ mod height_tests { AnchoredObject::new(designcraft_doc::Item::new(ItemId(0), LayerId(0), designcraft_doc::Shape::Rectangle, path), position) } + #[test] + fn cell_first_baseline_option_sets_the_first_line_and_the_row_height() { + // Cell Options › Text › First Baseline: Cap Height sets the first line by the cap height, + // not the typographic ascent, so a row sized for one line of caps keeps its height. + let ascent = text_cell("Hd", 4.0); + let mut cap = ascent.clone(); + cap.first_baseline = designcraft_doc::FirstBaseline::CapHeight; + let (a, c) = (measure(&ascent, 92.0), measure(&cap, 92.0)); + let (la, lc) = (&a.text.frames[0].lines[0], &c.text.frames[0].lines[0]); + assert!(lc.baseline < la.baseline, "cap height {} should sit above ascent {}", lc.baseline, la.baseline); + assert!(c.content_h < a.content_h); + let mut fixed = ascent.clone(); + fixed.first_baseline = designcraft_doc::FirstBaseline::Fixed; + fixed.first_baseline_min = 6.0; + assert!((measure(&fixed, 92.0).text.frames[0].lines[0].baseline - 6.0).abs() < 1e-6); + } + #[test] fn cell_bottom_inset_contains_descenders_without_growing_every_row() { let cell = text_cell("gyp", 8.5); diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 72590d47c..5c747fdbf 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -454,6 +454,11 @@ pub struct CellStyle { pub inset_overrides: [Option; 4], #[serde(skip_serializing_if = "Option::is_none")] pub vj: Option, + /// Cell Options › Text › First Baseline, and its minimum. + #[serde(skip_serializing_if = "Option::is_none")] + pub first_baseline: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_baseline_min: Option, /// All four edges. #[serde(skip_serializing_if = "Option::is_none")] pub stroke: Option, @@ -529,6 +534,12 @@ impl CellStyle { if let Some(v) = self.vj { cell.vj = v; } + if let Some(v) = self.first_baseline { + cell.first_baseline = v; + } + if let Some(v) = self.first_baseline_min { + cell.first_baseline_min = v; + } if let Some(v) = &self.stroke { cell.strokes = [v.clone(), v.clone(), v.clone(), v.clone()]; cell.border_overrides = [true; 4]; @@ -698,6 +709,12 @@ pub fn resolve_cell_style(cells: &[CellStyle], name: &str) -> CellStyle { if style.vj.is_some() { out.vj = style.vj; } + if style.first_baseline.is_some() { + out.first_baseline = style.first_baseline; + } + if style.first_baseline_min.is_some() { + out.first_baseline_min = style.first_baseline_min; + } if let Some(stroke) = &style.stroke { out.strokes = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(stroke)); } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 14e02a7cb..5d109d160 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use crate::ids::StoryId; -use crate::item::{StrokeType, VerticalJustification}; +use crate::item::{FirstBaseline, StrokeType, VerticalJustification}; use crate::story::{CharFormat, ParaFormat, Story, TABLE_ANCHOR}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -159,6 +159,12 @@ fn full() -> f32 { fn no_fill() -> String { designcraft_color::swatch::NONE.into() } +fn is_ascent(f: &FirstBaseline) -> bool { + *f == FirstBaseline::Ascent +} +fn is_zero_min(v: &f64) -> bool { + *v == 0.0 +} fn default_insets() -> [f64; 4] { [4.0; 4] } @@ -181,6 +187,12 @@ pub struct Cell { pub insets: [f64; 4], #[serde(default)] pub vj: VerticalJustification, + /// Cell Options › Text › First Baseline: how far the first line's baseline sits below the + /// top inset (IDML `FirstBaselineOffset`), and its minimum (`MinimumFirstBaselineOffset`). + #[serde(default, skip_serializing_if = "is_ascent")] + pub first_baseline: FirstBaseline, + #[serde(default, skip_serializing_if = "is_zero_min")] + pub first_baseline_min: f64, /// Text rotation in degrees (only 0 is composed for now). #[serde(default)] pub rotation: f64, @@ -216,6 +228,8 @@ impl Default for Cell { fill_tint: 1.0, insets: default_insets(), vj: VerticalJustification::Top, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, rotation: 0.0, strokes: Default::default(), border_overrides: [false; 4], diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index af3221054..7cf7c271a 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -30,7 +30,7 @@ fn style_specs() -> Vec { "New Cell Style…", [], None, - "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}", + "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}", has_doc, |s, p| cell_style_create(s, p) ), @@ -297,7 +297,7 @@ fn table_specs() -> Vec { "Cell Options", [], None, - "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", + "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", in_table, set_cell ), @@ -932,6 +932,22 @@ fn drawn_side(t: &Table, owners: &[(usize, usize)], (r, c): (usize, usize), side drawn.or_else(|| t.cell(r, c).and_then(|cell| cell.strokes.get(side).cloned())) } +/// Cell Options › Text › First Baseline: `firstBaseline` (offset method) and `firstBaselineMin` (pt). +fn first_baseline_params(p: &Value, id: &str) -> Result<(Option, Option)> { + let fb = match p.get("firstBaseline").filter(|v| !v.is_null()) { + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(id, format!("firstBaseline: {e}")))?), + None => None, + }; + let min = match p.get("firstBaselineMin").filter(|v| !v.is_null()) { + Some(v) => match v.as_f64().filter(|m| m.is_finite() && (0.0..=8640.0).contains(m)) { + Some(m) => Some(m), + None => return Err(bad(id, "firstBaselineMin: a number of points from 0 to 8640")), + }, + None => None, + }; + Ok((fb, min)) +} + fn set_cell(s: &mut Session, p: &Value) -> Result { const ID: &str = "table.setCell"; let g = target(s, p, ID)?; @@ -954,6 +970,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))), None => None, }; + let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?; let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = match p.get("stroke").filter(|v| !v.is_null()) { Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)), @@ -998,6 +1015,12 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { if let Some(v) = vj { cell.vj = v; } + if let Some(v) = first_baseline { + cell.first_baseline = v; + } + if let Some(v) = first_baseline_min { + cell.first_baseline_min = v; + } if let Some(tx) = &text { let len = cell.text.len(); cell.text.replace(0..len, tx); @@ -1525,7 +1548,7 @@ fn get(s: &mut Session, p: &Value) -> Result { .filter_map(|(r, c)| { let cell = t.cell(r, c)?; Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, - "insets": cell.insets, "vj": cell.vj, "style": cell.style})) + "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style})) }) .collect(); Ok(json!({ @@ -1572,6 +1595,13 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Opt if let Some(v) = p.get("vj") { cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?); } + let (first_baseline, first_baseline_min) = first_baseline_params(p, cmd)?; + if first_baseline.is_some() { + cs.first_baseline = first_baseline; + } + if first_baseline_min.is_some() { + cs.first_baseline_min = first_baseline_min; + } if let Some(st) = stroke { cs.stroke = Some(st); cs.strokes = Default::default(); @@ -1600,6 +1630,8 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result { cs.fill_tint = Some(cell.fill_tint); cs.insets = Some(cell.insets); cs.vj = Some(cell.vj); + cs.first_baseline = Some(cell.first_baseline); + cs.first_baseline_min = Some(cell.first_baseline_min); cs.stroke = Some(cell.strokes[0].clone()); cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone()); } @@ -1787,6 +1819,28 @@ mod style_tests { use crate::Session; + #[test] + fn cell_first_baseline_is_a_cell_option_and_a_cell_style_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "capHeight", "firstBaselineMin": 3})).unwrap(); + let got = s.execute("table.get", &json!({})).unwrap(); + let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone(); + assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0))); + assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err()); + assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err()); + s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap(); + let d = &s.doc().unwrap().doc; + let t = d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap(); + assert_eq!(t.cell(1, 1).unwrap().first_baseline, designcraft_doc::FirstBaseline::Leading); + assert_eq!(t.cell(1, 0).unwrap().first_baseline, designcraft_doc::FirstBaseline::Ascent); + } + #[test] fn cell_and_table_styles_apply_and_follow_edits() { let mut s = Session::new(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index a773ec9e5..86fef8e46 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -923,6 +923,12 @@ impl<'a> Ex<'a> { if let Some(vj) = cs.vj { el.set("VerticalJustification", names::vj_out(vj)); } + if let Some(fb) = cs.first_baseline { + el.set("FirstBaselineOffset", names::first_baseline_out(fb)); + } + if let Some(min) = cs.first_baseline_min { + el.set("MinimumFirstBaselineOffset", num(min)); + } if let Some(st) = &cs.stroke { for side in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"] { self.cell_stroke_attrs(&mut el, side, st); @@ -2770,6 +2776,8 @@ impl<'a> Ex<'a> { .attr("BottomInset", num(cell.insets[2])) .attr("RightInset", num(cell.insets[3])) .attr("VerticalJustification", vj) + .attr("FirstBaselineOffset", names::first_baseline_out(cell.first_baseline)) + .attr("MinimumFirstBaselineOffset", num(cell.first_baseline_min)) .attr("RotationAngle", num(cell.rotation)); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { // Do not turn absent native defaults into explicit shared-edge candidates. diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 5cd3746a9..3493d60a7 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1189,6 +1189,8 @@ impl<'r> Importer<'r> { cs.inset_overrides = [None; 4]; } cs.vj = e.get("VerticalJustification").map(names::vj_in); + cs.first_baseline = e.get("FirstBaselineOffset").map(names::first_baseline_in); + cs.first_baseline_min = e.num("MinimumFirstBaselineOffset").map(|v| v.max(0.0)); cs.strokes = ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|side| self.cell_stroke_attrs(e, side)); if let Some(stroke) = uniform_cell_stroke(&cs.strokes) { cs.stroke = Some(stroke); @@ -2213,6 +2215,12 @@ impl<'r> Importer<'r> { if let Some(vj) = ce.get("VerticalJustification") { cell.vj = names::vj_in(vj); } + if let Some(fb) = ce.get("FirstBaselineOffset") { + cell.first_baseline = names::first_baseline_in(fb); + } + if let Some(min) = ce.num("MinimumFirstBaselineOffset") { + cell.first_baseline_min = min.max(0.0); + } cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { let attrs = self.cell_stroke_attrs(ce, side); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 3f5b7977d..c5d7ec782 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4189,3 +4189,44 @@ mod liquid_page_rules { } } } + +/// Cell Options › Text › First Baseline, set by a cell style that a table style applies to its body +/// region (cells with `[None]` take it from there) and overridden on one cell. +const FIRST_BASELINE_STYLES: &str = r#" + + + + cs-cap + + + + +"#; + +const FIRST_BASELINE_STORY: &str = r#" + + + +Region +Local +
"#; + +#[test] +fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_trips() { + use designcraft_doc::FirstBaseline; + let d = import_idml(&fixture_with_table_styles(FIRST_BASELINE_STYLES, FIRST_BASELINE_STORY)).unwrap(); + d.check().unwrap(); + let style = d.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); + assert_eq!((style.first_baseline, style.first_baseline_min), (Some(FirstBaseline::CapHeight), Some(2.0))); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let region = table.cell(0, 0).unwrap(); + assert_eq!((region.first_baseline, region.first_baseline_min), (FirstBaseline::CapHeight, 2.0), "inherited through BasedOn and the body region"); + assert_eq!(table.cell(0, 1).unwrap().first_baseline, FirstBaseline::Leading, "a cell's own attribute wins"); + let back = import_idml(&export_idml(&d)).unwrap(); + let t2 = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(t2.cell(0, 0).unwrap().first_baseline, FirstBaseline::CapHeight); + assert_eq!(t2.cell(0, 0).unwrap().first_baseline_min, 2.0); + assert_eq!(t2.cell(0, 1).unwrap().first_baseline, FirstBaseline::Leading); + let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); + assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); +} From 17848dd1e9f21d6f475c2875256dfeaf50a855eb Mon Sep 17 00:00:00 2001 From: Paul G <4waymedia@gmail.com> Date: Sat, 17 Oct 2026 17:11:31 -0700 Subject: [PATCH 22/41] Package: link the packaged document and IDML relative to the package (fixes #340) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EDKUo6t8zLKhzeuPrJxa3n --- ROADMAP.md | 1 + crates/engine/src/cmd/interchange.rs | 12 ++++++- crates/engine/src/cmd/package.rs | 51 +++++++++++++++++++++++----- docs/gaps.md | 5 ++- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..d3729509c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,7 @@ # DesignCraft roadmap **Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) +- PK340: 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. - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index a33525aa1..bf0e011a0 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -254,7 +254,17 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, package: Option<&s let Some(package) = package else { return }; for asset in document.assets.values_mut() { let Some(link) = asset.link.as_deref() else { continue }; - if link.len() > MAX_LINK_PATH_BYTES || crate::io::document_path(link, Some(package)).is_ok_and(crate::io::is_file) { + if link.len() > MAX_LINK_PATH_BYTES { + continue; + } + if let Ok(path) = crate::io::document_path(link, Some(package)) + && crate::io::is_file(&path) + { + // A link relative to the document (what File › Package writes, `Links/logo.png`) is + // held as the file it names, as every other link is, so preflight and updates find it. + if std::path::Path::new(link).is_relative() { + std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned()); + } continue; } let Some(path) = packaged_link_candidates(link, Some(package)).into_iter().find(|p| crate::io::is_file(p)) else { continue }; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index b953fdc75..f2d33ccfa 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -18,6 +18,9 @@ use serde_json::{Value, json}; const MAX_OUTPUT_COMPONENT_BYTES: usize = 200; +/// The folder of a package that holds the files its document places. +const LINKS_FOLDER: &str = "Links"; + fn path_error(path: &Path, message: impl std::fmt::Display) -> EngineError { EngineError::Other(format!("{}: {message}", path.display())) } @@ -282,7 +285,10 @@ fn unique_name(base: String, taken: &mut Vec) -> String { } /// Write each asset's bytes into `dir`; returns the relinked document and the paths written. -fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec)> { +/// `relative_to` names the folder `dir` is, beside the saved document: the links then read +/// `Links/logo.png`, so a package holds no path from the machine that made it (#340). `None` links +/// the copies by their full paths (Links › Copy Link(s) To, which relinks the open document). +fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>, relative_to: Option<&str>) -> Result<(Document, Vec)> { root.validate_directory(dir)?; let mut out = d.clone(); let mut taken = Vec::new(); @@ -303,7 +309,10 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset let path = dir.join(&name); root.write(&path, a.data.as_slice())?; let mut na = (**a).clone(); - na.link = Some(path.to_string_lossy().to_string()); + na.link = Some(match relative_to { + Some(folder) => format!("{folder}/{name}"), + None => path.to_string_lossy().to_string(), + }); out.assets.insert(id, Arc::new(na)); written.push(path); } @@ -387,8 +396,8 @@ fn package(s: &mut Session, p: &Value) -> Result { let links = s.execute("links.list", &json!({}))?; let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null); let d = s.doc()?.doc.clone(); - let links_dir = root.child("Links")?; - let (packed, mut files) = write_links(&d, &root, &links_dir, None)?; + let links_dir = root.child(LINKS_FOLDER)?; + let (packed, mut files) = write_links(&d, &root, &links_dir, None, Some(LINKS_FOLDER))?; let doc_path = package_output_path(&dir, &title, "designcraft")?; root.write(&doc_path, &super::to_bytes(&packed)?)?; files.insert(0, doc_path); @@ -463,7 +472,7 @@ fn copy_links(s: &mut Session, p: &Value) -> Result { let only: Option> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(AssetId).collect()); let d = s.doc()?.doc.clone(); let root = OutputRoot::new(dir.clone())?; - let (relinked, written) = write_links(&d, &root, &dir, only.as_deref())?; + let (relinked, written) = write_links(&d, &root, &dir, only.as_deref(), None)?; s.edit(|doc, _| { doc.assets = relinked.assets; Ok(()) @@ -520,14 +529,17 @@ mod tests { std::io::Read::read_to_string(&mut f, &mut spreads).unwrap(); } } - let linked = dir.join("Links").join("my photo.png"); - let uri = format!("file:{}", linked.to_string_lossy().replace(' ', "%20")); + // Relative to the package: no path from the machine that made it (#340). + let uri = "file:Links/my%20photo.png"; + let folder = dir.file_name().unwrap().to_string_lossy().into_owned(); + assert!(!spreads.contains(&folder), "the package's own folder is not written: {spreads}"); assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}"); assert!(!spreads.contains(" **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -57,8 +57,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Missing:** a corpus test of real InDesign-authored IDML (via `storytold/photocraft-corpus` or a sibling) measuring what survives import → export → reopen in InDesign. Open bugs: frames not rendered without a warning (#193), `$ID/` font names reported missing (#338), no - `PDFAttribute` on export (#339), Package writes build-machine absolute paths (#340), drag-drop - open of IDML on the app icon (#211). + `PDFAttribute` on export (#339), drag-drop open of IDML on the app icon (#211). - **Evidence:** importer coverage is broad (styles, tables, footnotes, xrefs, index, CJK, Arabic, verified in InDesign 2026 with synthetic files), but nothing is measured on real documents. - **Impact:** IDML is the main interchange format until INDD lands, and the only one that goes From b20009076ef5249bc022c4e540ccacf9feefa312 Mon Sep 17 00:00:00 2001 From: Paul G <4waymedia@gmail.com> Date: Sat, 17 Oct 2026 17:20:45 -0700 Subject: [PATCH 23/41] Contributors: credit Paul Gemignani --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 7a33de655..2f44f93d4 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -65,3 +65,6 @@ sync_github_name = false real_name = "Marco Arnulfo" display_name = "Marco Arnulfo" sync_github_name = false + +[people.4waymedia] +real_name = "Paul Gemignani" From b8ae284a4a69cd84b65b7a32453602922651fc0d Mon Sep 17 00:00:00 2001 From: Hamza Date: Sun, 18 Oct 2026 20:47:01 +0500 Subject: [PATCH 24/41] View: Bleed and Slug screen modes show their area; draw the slug guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bleed and Slug were selectable from the toolbar, the View menu and `view.screenMode`, and were stored, but the canvas only ever asked whether the mode was Preview or Presentation. Both therefore drew exactly what Normal drew: guides, frame edges, the other non-printing marks, and content reaching across the pasteboard. They are preview modes now, and what each spread shows reaches out to its box: the bleed for Bleed, the bleed and the slug for Slug (Slug shows every printing element, and a slug narrower than the bleed does not pull the edge back in). Preview and Presentation still stop at the trim. The slug guide was never drawn either, so the Slug colour in Preferences › Guides & Pasteboard was stored, exported and round-tripped while nothing read it. It is drawn beside the bleed guide, in that colour. `Spread::bleed_rect`'s body becomes `outset_rect`, which measures either offset from the page edges, with `bleed_rect` and `slug_rect` as its named callers. --- ROADMAP.md | 1 + crates/doc/src/page.rs | 23 +++++-- crates/tools/src/lib.rs | 2 +- crates/ui-egui/src/canvas.rs | 113 ++++++++++++++++++++++++++++++++--- 4 files changed, 124 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..a14b6da09 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-18** · View › Screen Mode: Bleed and Slug do what they say. Both were selectable from the toolbar, the View menu and `view.screenMode` but drew the canvas exactly as Normal did, guides and all; they are preview modes now, and what each page shows reaches the bleed box or the slug box instead of stopping at the trim. The slug guide is drawn as well, in the Slug colour from Preferences › Guides & Pasteboard, which nothing had read. 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/doc/src/page.rs b/crates/doc/src/page.rs index 89465448e..862a1cda9 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -301,17 +301,27 @@ impl Spread { pub fn bounds(&self) -> Rect { self.pages.iter().map(Page::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO) } - /// The bleed guide: one rectangle around the spread. `bleed` is the document's top, bottom, + /// The bleed guide: one rectangle around the spread. None without pages or without any bleed. + pub fn bleed_rect(&self, bleed: [f64; 4]) -> Option { + self.outset_rect(bleed) + } + /// The slug guide. Slug offsets are measured from the page edges, like the bleed, so a slug + /// narrower than the bleed sits inside it, as in InDesign. None without pages or without any + /// slug. + pub fn slug_rect(&self, slug: [f64; 4]) -> Option { + self.outset_rect(slug) + } + /// The spread's bounds grown by a document outset (the bleed or the slug): top, bottom, /// inside, outside; the inside value applies only at an outer edge on the spine side (a lone /// left page's right edge). The outer edges are the leftmost and rightmost pages, whichever - /// binding ordered the list. None without pages or without any bleed. - pub fn bleed_rect(&self, bleed: [f64; 4]) -> Option { + /// binding ordered the list. + pub fn outset_rect(&self, outset: [f64; 4]) -> Option { let (l, r) = self.outer_page_indices()?; let (first, last) = (self.pages.get(l)?, self.pages.get(r)?); - if !bleed.iter().any(|v| *v > 0.0) { + if !outset.iter().any(|v| *v > 0.0) { return None; } - let [top, bottom, inside, outside] = bleed; + let [top, bottom, inside, outside] = outset; let l = if first.side == PageSide::Left { outside } else { inside }; let r = if matches!(last.side, PageSide::Right | PageSide::Single) { outside } else { inside }; let b = self.bounds(); @@ -464,6 +474,9 @@ mod tests { let s = Spread { id: SpreadId(1), pages, items: vec![], parent: None, allow_shuffle: true }; assert_eq!(s.bleed_rect(bleed), Some(Rect::new(-4.0, -1.0, 1228.0, 794.0))); assert_eq!(s.bleed_rect([0.0; 4]), None); + // The slug is measured the same way, from the page edges. + assert_eq!(s.slug_rect(bleed), s.bleed_rect(bleed)); + assert_eq!(s.slug_rect([0.0; 4]), None); } let lone_left = Spread { id: SpreadId(1), pages: vec![page(PageSide::Left, 0.0)], items: vec![], parent: None, allow_shuffle: true }; assert_eq!(lone_left.bleed_rect(bleed), Some(Rect::new(-4.0, -1.0, 615.0, 794.0))); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 33784f6fb..a92f1c132 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -32,7 +32,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; pub use catalog::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info}; -pub use layout::CanvasLayout; +pub use layout::{CanvasLayout, SpreadSlot}; pub use nav::ZoomTool; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34e1aa2fd..8d94f099f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5,7 +5,7 @@ use designcraft_compose as compose; use designcraft_doc::{Content, Document, Item, SpreadRef}; use designcraft_engine::doc::Selection; use designcraft_geom::{Affine, Point, Rect as DRect, Vec2 as DVec2}; -use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolKey}; +use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, SpreadSlot, ToolKey}; use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value, json}; @@ -477,7 +477,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { layout.slots = layout.slots.get(cur).copied().into_iter().collect(); } let painter = ui.painter_at(screen); - let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation); + // Preview, Bleed, Slug and Presentation are all preview modes: guides, frame edges and the + // other non-printing marks go, and only the shown area of each page keeps its content. They + // differ in how much of the page shows, which `shown_area` gives. + let preview = app.ui.screen_mode != ScreenMode::Normal; let bg = if app.ui.screen_mode == ScreenMode::Presentation { Color32::BLACK } else { t.pasteboard }; painter.rect_filled(screen, 0.0, bg); // Page shadow: a hard 1.5 pt black offset on the right and bottom (InDesign 2026). @@ -514,9 +517,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } mesh.indices.extend([0, 1, 2, 0, 2, 3]); if preview { - // Preview: only page areas (trim) show content. + // Preview: only the shown area of each spread keeps its content. for slot in &layout.slots { - let r = xf.rect(slot.bounds); + let r = shown_area(app, &doc, &xf, slot); painter.with_clip_rect(r.intersect(screen)).add(egui::Shape::mesh(mesh.clone())); } } else { @@ -933,6 +936,20 @@ fn baseline_grid_lines(start: f64, increment: f64, height: f64) -> impl Iterator (0..MAX_BASELINES).map(move |n| first + n as f64 * step).take_while(move |y| *y < height) } +/// What a screen mode shows of a spread, in screen coordinates: the trim, or out to the bleed or +/// the slug box. Slug shows every printing element, so the bleed as well — and a slug narrower +/// than the bleed does not pull the edge back in. +fn shown_area(app: &DesignApp, doc: &Document, xf: &Xf, slot: &SpreadSlot) -> Rect { + let trim = xf.rect(slot.bounds); + let boxes: &[[f64; 4]] = match app.ui.screen_mode { + ScreenMode::Bleed => &[doc.settings.bleed], + ScreenMode::Slug => &[doc.settings.bleed, doc.settings.slug], + _ => &[], + }; + let Some(sp) = doc.spread(slot.spread) else { return trim }; + boxes.iter().filter_map(|o| sp.outset_rect(*o)).map(|r| xf.rect(slot.xf.transform_rect_bbox(r))).fold(trim, |a, b| a.union(b)) +} + fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let s = &doc.settings; for slot in &layout.slots { @@ -941,11 +958,14 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document let m = slot.xf; let pt = |p: Point| xf.to_screen(m * p); let rect = |r: DRect| xf.rect(m.transform_rect_bbox(r)); - // Bleed: one rectangle around the spread (inside bleed only applies at the spread's outer edges). - if app.ui.guides - && let Some(br) = sp.bleed_rect(s.bleed) - { - painter.rect_stroke(rect(br), 0.0, Stroke::new(hair(painter), c32(s.bleed_color)), StrokeKind::Middle); + // Bleed, then slug: one rectangle each around the spread (the inside offset only applies + // at the spread's outer edges). + if app.ui.guides { + for (r, color) in [(sp.bleed_rect(s.bleed), s.bleed_color), (sp.slug_rect(s.slug), s.slug_color)] { + if let Some(r) = r { + painter.rect_stroke(rect(r), 0.0, Stroke::new(hair(painter), c32(color)), StrokeKind::Middle); + } + } } let pasteboard = m.inverse().transform_rect_bbox(layout.pasteboard); for (pi, p) in sp.pages.iter().enumerate() { @@ -3028,6 +3048,81 @@ mod tests { } } + /// Bleed and Slug are preview modes: like Preview they hide the guides, and what the page + /// shows widens to the bleed or the slug box. The slug guide is drawn in Normal, in its own + /// colour, beside the bleed guide. + #[test] + fn bleed_and_slug_are_preview_modes_that_show_their_area() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"bleed": 9, "slug": 18})).unwrap(); + let strokes = |app: &mut DesignApp, want: [u8; 3]| -> usize { + let ctx = egui::Context::default(); + let mut shapes = Vec::new(); + for _ in 0..2 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + shapes = out.shapes; + } + let want = c32(want); + shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(s) if s.stroke.color == want)).count() + }; + let s = app.session.active().unwrap().doc.settings.clone(); + // Normal draws every guide, the slug's among them. + app.ui.screen_mode = ScreenMode::Normal; + assert!(strokes(&mut app, s.margin_color) > 0, "Normal draws the margin guide"); + assert!(strokes(&mut app, s.bleed_color) > 0, "Normal draws the bleed guide"); + assert!(strokes(&mut app, s.slug_color) > 0, "Normal draws the slug guide"); + // Every preview mode hides them. + for mode in [ScreenMode::Preview, ScreenMode::Bleed, ScreenMode::Slug, ScreenMode::Presentation] { + app.ui.screen_mode = mode; + assert_eq!(strokes(&mut app, s.margin_color), 0, "{mode:?} hides the margin guide"); + assert_eq!(strokes(&mut app, s.slug_color), 0, "{mode:?} hides the slug guide"); + } + } + + /// Each preview mode's shown area: Preview stops at the trim, Bleed reaches the bleed box and + /// Slug the slug box. A slug narrower than the bleed never pulls the edge back in. + #[test] + fn shown_area_widens_to_the_bleed_and_slug_boxes() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({"bleed": 9, "slug": 18})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let layout = CanvasLayout::new(&doc, false); + let xf = Xf::new(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0)), &View::default()); + let slot = &layout.slots[0]; + let area = |app: &mut DesignApp, mode| { + app.ui.screen_mode = mode; + shown_area(app, &doc, &xf, slot) + }; + let trim = xf.rect(slot.bounds); + assert_eq!(area(&mut app, ScreenMode::Preview), trim, "Preview stops at the trim"); + assert_eq!(area(&mut app, ScreenMode::Presentation), trim, "Presentation stops at the trim"); + let bleed = area(&mut app, ScreenMode::Bleed); + let slug = area(&mut app, ScreenMode::Slug); + assert!(bleed.contains_rect(trim) && bleed != trim, "Bleed reaches past the trim"); + assert!(slug.contains_rect(bleed) && slug != bleed, "Slug reaches past the bleed"); + // 9 pt of bleed and 18 pt of slug at this zoom, on every edge. + let grew = |r: Rect, pt: f64| { + let d = (pt * xf.zoom) as f32; + (r.min - trim.min + vec2(d, d)).length() < 0.01 && (r.max - trim.max - vec2(d, d)).length() < 0.01 + }; + assert!(grew(bleed, 9.0), "Bleed grows by the bleed: {bleed:?}"); + assert!(grew(slug, 18.0), "Slug grows by the slug: {slug:?}"); + // A slug inside the bleed: Slug still shows every printing element, so the bleed too. + app.run("file.new", serde_json::json!({"bleed": 18, "slug": 9})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + app.ui.screen_mode = ScreenMode::Slug; + assert!(grew(shown_area(&app, &doc, &xf, slot), 18.0), "the wider of the two boxes wins"); + // No bleed and no slug: nothing to widen to. + app.run("file.new", serde_json::json!({})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + for mode in [ScreenMode::Bleed, ScreenMode::Slug] { + app.ui.screen_mode = mode; + assert_eq!(shown_area(&app, &doc, &xf, slot), trim, "{mode:?} without a bleed or slug"); + } + } + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { From 526eeda545c205014783cbb22d924f67d14b0e18 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sun, 18 Oct 2026 18:04:22 +0200 Subject: [PATCH 25/41] Document Setup: read a number without a unit in the document's units (fixes #1247) --- crates/ui-egui/src/dialogs.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4b..8b4043787 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1236,7 +1236,9 @@ pub fn open_document_setup(app: &mut DesignApp) { } // The margins as shown: OK applies them to every page only when they were edited. f["marginsShown"] = json!(SETUP_EDGES.map(|e| f[format!("margin{e}")].clone())); - app.ui.dialog = Some(Dialog::new("documentSetup", f)); + let mut d = Dialog::new("documentSetup", f); + d.unit = units; + app.ui.dialog = Some(d); } /// The edges of Document Setup's margins, bleed and slug (field suffixes). @@ -7076,6 +7078,17 @@ mod tests { assert!((doc.settings.page_width - 612.0).abs() < 1e-6 && doc.settings.bleed[0] == 0.0, "one undo step"); } + #[test] + fn document_setup_reads_a_bare_number_in_the_documents_units() { + let mut app = document_setup_app("millimeters"); + setup_fields(&mut app, json!({"bleedTop": "5", "bleedBottom": "5", "bleedInside": "5", "bleedOutside": "5", "width": "210"})); + confirm(&mut app).unwrap(); + let mm = designcraft_geom::units::PT_PER_MM; + let settings = &app.session.active().unwrap().doc.settings; + assert!(settings.bleed.iter().all(|b| (b - 5.0 * mm).abs() < 1e-6), "5 mm, not 5 picas: {:?}", settings.bleed); + assert!((settings.page_width - 210.0 * mm).abs() < 1e-6, "{}", settings.page_width); + } + #[test] fn document_setup_keeps_each_pages_margins_unless_they_are_edited() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From ecd7e5d063eb12fa5e61fe61c9b8a77bb3c9110a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 14:31:22 -0400 Subject: [PATCH 26/41] ci: configure Dependabot for Cargo and GitHub Actions Co-authored-by: Dylan M. Taylor --- .github/dependabot.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2c4b8cb1a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" From 103d7eb3ff87f04fc42fe346c101fcd7e624cd28 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:59:59 +0000 Subject: [PATCH 27/41] Clamp object.stroke tint and gapTint to 0..1 Found while testing crates/engine/src/cmd/object.rs:299-313 (object.stroke: `st.tint = v as f32`, `st.gap_tint = v as f32`; the no-selection path at :273 clamps, this one doesn't) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/object.rs | 4 ++-- crates/engine/tests/stroke_tint_range.rs | 25 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/stroke_tint_range.rs diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 2d5817600..e766eeea9 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -297,7 +297,7 @@ pub fn specs() -> Vec { st.swatch = v.into(); } if let Some(v) = p2.get("tint").and_then(Value::as_f64) { - st.tint = v as f32; + st.tint = v.clamp(0.0, 1.0) as f32; } if let Some(v) = p2.get("weight").and_then(Value::as_f64) { st.weight = v.max(0.0); @@ -309,7 +309,7 @@ pub fn specs() -> Vec { st.gap_swatch = v.into(); } if let Some(v) = p2.get("gapTint").and_then(Value::as_f64) { - st.gap_tint = v as f32; + st.gap_tint = v.clamp(0.0, 1.0) as f32; } if let Some(v) = p2.get("gapOverprint").and_then(Value::as_bool) { st.gap_overprint = v; diff --git a/crates/engine/tests/stroke_tint_range.rs b/crates/engine/tests/stroke_tint_range.rs new file mode 100644 index 000000000..40aca49cc --- /dev/null +++ b/crates/engine/tests/stroke_tint_range.rs @@ -0,0 +1,25 @@ +//! `object.stroke` keeps `tint` and `gapTint` within 0..1, so a saved document always reloads. +use designcraft_doc::ItemId; +use designcraft_engine::Session; +use serde_json::json; + +fn stroked(tint: f64, gap_tint: f64) -> (Session, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let created = s.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap(); + let id = ItemId(created["id"].as_u64().unwrap()); + s.execute("object.stroke", &json!({"ids": [id.0], "swatch": "[Black]", "tint": tint, "gapTint": gap_tint})).unwrap(); + (s, id) +} + +#[test] +fn out_of_range_stroke_tints_are_clamped_and_the_saved_file_reloads() { + for (tint, gap, want_tint, want_gap) in [(1e300, 1e300, 1.0, 1.0), (2.0, -1.0, 1.0, 0.0), (-1e300, 0.5, 0.0, 0.5)] { + let (s, id) = stroked(tint, gap); + let doc = &s.doc().unwrap().doc; + let st = &doc.item(id).unwrap().stroke; + assert_eq!((st.tint, st.gap_tint), (want_tint, want_gap), "tint {tint}, gapTint {gap}"); + let bytes = designcraft_format::save(doc).unwrap(); + designcraft_format::load(&bytes).expect("reload what we just saved"); + } +} From 6fc7172628cc7e97862bd05f1f1af10d789ab462 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:35:56 +0200 Subject: [PATCH 28/41] Let single-line paths be resized along their length (#1245) --- crates/engine/src/cmd/object.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 2d5817600..bea26c596 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1201,11 +1201,10 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result { let to = rect_param(p, "to").ok_or_else(|| bad("transform.resize", "missing to"))?; let scale_content = bool_or(p, "content", false); let distribute = bool_or(p, "distribute", false); - if from.width().abs() < 1e-9 || from.height().abs() < 1e-9 { - return Err(bad("transform.resize", "degenerate source rect")); - } - let sx = to.width() / from.width(); - let sy = to.height() / from.height(); + // A flat source (a horizontal or vertical line) has no size to scale on its flat axis: that axis only moves. + let ratio = |new: f64, old: f64| if old.abs() < 1e-9 { 1.0 } else { new / old }; + let sx = ratio(to.width(), from.width()); + let sy = ratio(to.height(), from.height()); let sx = if sx.abs() < 1e-3 { 1e-3f64.copysign(sx) } else { sx }; let sy = if sy.abs() < 1e-3 { 1e-3f64.copysign(sy) } else { sy }; let m = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0)); @@ -4189,6 +4188,16 @@ mod fitting_tests { assert!(String::from_utf8_lossy(&bytes).contains("/Subtype/Image")); } + #[test] + fn a_flat_line_resizes_along_its_length() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("line.create", &json!({"a": [100, 100], "b": [200, 100]})).unwrap()["id"].as_u64().unwrap(); + s.execute("transform.resize", &json!({"ids": [id], "from": [100, 100, 200, 100], "to": [100, 100, 300, 100]})).unwrap(); + let r = s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds(); + assert!((r.x0 - 100.0).abs() < 1e-9 && (r.x1 - 300.0).abs() < 1e-9 && (r.y0 - 100.0).abs() < 1e-9 && r.height().abs() < 1e-9, "{r:?}"); + } + #[test] fn live_distribute_spreads_objects_without_resizing() { let mut s = Session::new(); From 6e0b50a819ca24ac21808753d7656aafa971dbd1 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:35:57 +0200 Subject: [PATCH 29/41] Show New Document preset sizes in the selected units (#1260) --- crates/ui-egui/src/new_document.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index ad415c83d..ee59de800 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -360,9 +360,8 @@ fn cards(app: &DesignApp, tab: &str) -> Vec { } } -/// `210 × 297 mm`, `51p0 × 66p0`. -fn size_text(nd: &NewDocument) -> String { - let u = nd.units; +/// `210 × 297 mm`, `51p0 × 66p0`: the size of `nd` in the dialog's units `u`. +fn size_text(nd: &NewDocument, u: Unit) -> String { let (w, h) = (format_measure(nd.width, u), format_measure(nd.height, u)); let suffix = u.suffix(); if suffix.starts_with(' ') @@ -391,7 +390,7 @@ fn paint_page(p: &egui::Painter, area: Rect, nd: &NewDocument, custom: bool, col } } -fn card(ui: &mut Ui, c: &Card, selected: bool) -> egui::Response { +fn card(ui: &mut Ui, c: &Card, units: Unit, selected: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(CARD, Sense::click()); if selected { @@ -406,7 +405,7 @@ fn card(ui: &mut Ui, c: &Card, selected: bool) -> egui::Response { ui.painter(), pos2(r.center().x, r.top() + 132.0), Align2::CENTER_CENTER, - &size_text(&c.nd), + &size_text(&c.nd, units), FontId::proportional(11.0), t.text_dim, ); @@ -494,13 +493,14 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); ui.add_space(10.0); let current = d.s("preset"); + let card_units = units(d); let mut create = false; let mut delete = None; let layout = if rtl { Layout::right_to_left(Align::TOP) } else { Layout::left_to_right(Align::TOP) }.with_main_wrap(true); ui.with_layout(layout, |ui| { ui.spacing_mut().item_spacing = vec2(10.0, 10.0); for c in &list { - let resp = card(ui, c, current == c.key); + let resp = card(ui, c, card_units, current == c.key); if resp.clicked() || resp.double_clicked() { select(d, c); } @@ -1031,6 +1031,13 @@ mod tests { assert!(!doc.settings.facing_pages); } + #[test] + fn card_sizes_follow_the_dialog_units() { + let card = NewDocument::from_preset("Business Card").unwrap(); + assert_eq!(size_text(&card, Unit::Millimeters), "88.9 × 50.8 mm"); + assert_ne!(size_text(&card, Unit::Millimeters), size_text(&card, Unit::Picas)); + } + #[test] fn units_reformat_the_measures_and_linked_edges_follow() { // InDesign's Units list. From 251ceda1e018691de0686106660957698052df16 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:35:55 +0200 Subject: [PATCH 30/41] Reject invalid story.replaceRange offsets with an error (#1267) --- crates/engine/src/cmd/text.rs | 15 ++++++++++++--- docs/mcp.md | 4 +++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 24ff0384a..32cbfd0f6 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -130,7 +130,11 @@ pub fn specs() -> Vec { let text = super::text_param(p, "text"); s.edit(|d, sel| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len()))); + let n = st.len(); + if a > b || b > n || !st.text.is_char_boundary(a) || !st.text.is_char_boundary(b) { + let msg = format!("range {a}..{b} must be UTF-8 byte offsets on character boundaries, start <= end; the story is {n} bytes"); + return Err(bad("story.replaceRange", msg)); + } st.replace(a..b, &text); let len = st.len(); if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { @@ -2292,9 +2296,14 @@ mod open_type_tests { s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap(); let sid = r["story"].clone(); - // Offsets inside 漢 (2..5) and 🙂 (5..9) snap back to the character starts: 漢 is replaced. - s.execute("story.replaceRange", &json!({"story": sid, "start": 3, "end": 6, "text": "字"})).unwrap(); let story = designcraft_doc::StoryId(sid.as_u64().unwrap()); + // Offsets inside 漢 (2..5) and 🙂 (5..9), past the end, or reversed are refused and change nothing. + for (start, end) in [(3, 6), (2, 4), (0, 99), (5, 2)] { + let r = s.execute("story.replaceRange", &json!({"story": sid, "start": start, "end": end, "text": "字"})); + assert!(r.is_err_and(|e| e.to_string().contains("10 bytes")), "{start}..{end}"); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é漢🙂x"); + } + s.execute("story.replaceRange", &json!({"story": sid, "start": 2, "end": 5, "text": "字"})).unwrap(); assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é字🙂x"); // A caret before 🙂 (byte 5) would land inside it once é (2 bytes) is gone. s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index bba394e33..83e8bdf32 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -137,7 +137,9 @@ Positions in a story's text are **UTF-8 byte offsets**, not character counts: `a `start` / `end` of `story.replaceRange` and of `find.find` matches, the `pos` that `text.placeCaret`, `text.insert` and `text.move` return, a story's `length` in `get_story` and `inspect_document`, and its `overset` position in `get_story`. A character outside ASCII takes 2 to 4 bytes (é 2, — and は 3), so the story `はただ商店` has length 15 -and its second character starts at 3. An offset inside a character snaps back to the start of that character. +and its second character starts at 3. An offset inside a character snaps back to the start of that character, except +in `story.replaceRange`: there an offset inside a character, an `end` past the end of the story or a `start` after `end` +is an error that gives the story length in bytes, and the story is left unchanged. ## Text and image fitting From 551886c133030f5c2878b8114e49f7198d8f8cee Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:36:18 +0200 Subject: [PATCH 31/41] Show text stroke options in the Stroke panel when text is selected (#1265) --- crates/ui-egui/src/panels/properties.rs | 51 +++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fded030b9..44acf6e2c 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1864,6 +1864,12 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) { super::cell_stroke::panel(app, ui); return; } + if super::colors_affect_text(app) + && let Some(a) = text_attrs(app) + { + text_stroke_panel(app, ui, &a["chars"]); + return; + } let Some(i) = sel_info(app) else { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select an object.")); return; @@ -1985,6 +1991,27 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// Stroke panel while text is selected: the text's outline colour, weight and whether it sits behind the fill. +fn text_stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui, c: &Value) { + let swatch = c["stroke"].as_str().unwrap_or(designcraft_color::swatch::NONE).to_string(); + egui::Grid::new("text_stroke_panel").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + caption(ui, crate::i18n::tr(&app.ui.language, "Weight:")); + if let Some(v) = number(ui, "tsw_panel", c["strokeWeight"].as_f64(), " pt", 60.0, 3) { + let _ = app.run("type.char", json!({"attrs": {"strokeWeight": v}})); + } + ui.end_row(); + caption(ui, crate::i18n::tr(&app.ui.language, "Color:")); + super::swatch_picker(app, ui, "tspick", Some(swatch), |app, n| { + let _ = super::apply_swatch(app, true, &n, None); + }); + ui.end_row(); + }); + let mut behind = c["strokeBehindFill"].as_bool().unwrap_or(false); + if ui.checkbox(&mut behind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stroke Behind Fill"))).changed() { + let _ = app.run("type.char", json!({"attrs": {"strokeBehindFill": behind}})); + } +} + /// A short line ending in arrowhead `a` (pointing left for Start, right for End). fn arrow_preview(ui: &mut egui::Ui, a: Arrowhead, start: bool, color: egui::Color32) { use designcraft_geom::kurbo; @@ -2915,6 +2942,30 @@ mod tests { h } + /// With the caret in text, the Stroke panel offers the text's stroke options, not an empty panel. + #[test] + fn stroke_panel_shows_the_text_stroke_options_while_text_is_selected() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "caret": true})).unwrap(); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(320.0, 400.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), ""); + crate::theme::apply(ui.ctx(), &Tokens::for_brightness(Default::default())); + ready = true; + return; + } + stroke_panel(app, ui); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_label("Select an object.").is_none()); + h.get_by_label("Stroke Behind Fill"); + } + /// As InDesign's: Edit Page takes the Page tool to the page in view, and Properties sets that /// page alone; Back returns to the document. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4a7e9b99f..9e9ec6c39 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -439,7 +439,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel (InDesign's layout: thumbnail, Type, Location, Angle, Reverse) sets type, angle, reverse and edits stops and midpoints on a ramp (drag, add, drag off to remove, location; drop a swatch from the Swatches panel on a stop or the ramp; stops keep their CMYK), and follows the Fill/Stroke proxy. Edited gradients are unnamed swatches until the Swatches panel's New Swatch names one "New Gradient Swatch" (`swatch.new`, which also duplicates a named swatch); `swatch.create {gradient}` builds one from colour swatches; `object.gradient` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF). - New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset (`file.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. Preview shows a document with the window's settings in the workspace while it is open; Create keeps it, Close removes it. - Document Setup (⌥⌘P), laid out like InDesign's: intent, number of pages, start page # (an even start begins with a left page), facing pages, binding, Page Size (presets keeping the orientation, width and height with arrows, orientation), margins for every page and parent page, bleed and slug per edge (each group linked or not), and Adjust Layout (objects follow the new page size and margins), all in one undo step; measures are read in the document's units, and a value that can't be read keeps the dialog open; `layout.documentSetup {}` reports the setup, margins included. -- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. +- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots; with text selected (or text frames selected with Formatting Affects Text on) it shows the text's stroke weight, colour and Stroke Behind Fill. - Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. - Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu. - Based on Parent (`layout.parents.options {parent, basedOn}`): a parent shows its base parent's items under its own, on its pages and on the parent itself; chains work, cycles are refused, and items from any parent in the chain can be overridden. IDML carries it as the parent pages' `AppliedMaster`; a cycle or missing base in a file is cleared with a warning on open. The Pages panel has no Parent Options dialog yet. From 4df5d1639cff3b88ce85145b56daa870051989aa Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:40:40 +0200 Subject: [PATCH 32/41] Align Story Editor paragraph style labels with the wrapped text rows (#1251) --- crates/ui-egui/src/story_editor.rs | 59 ++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/story_editor.rs b/crates/ui-egui/src/story_editor.rs index fd7e618b1..2bd481d82 100644 --- a/crates/ui-egui/src/story_editor.rs +++ b/crates/ui-egui/src/story_editor.rs @@ -51,27 +51,26 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .show(ctx, |ui| { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { ui.horizontal_top(|ui| { - // Style column. - ui.vertical(|ui| { - ui.set_width(130.0); - for (i, r) in story.para_ranges().iter().enumerate() { - let lines = story.text[r.clone()].len() / 60 + 1; - let style = &story.paras[i].style; - crate::rtl::label(ui, egui::RichText::new(crate::i18n::style_name(&app.ui.language, style)).size(11.0).color(t.text_dim)); - for _ in 1..lines { - ui.label(egui::RichText::new(" ").size(11.0)); - } - } - }); + // Style column: the labels are painted after the text, at the rows the text really wraps to. + let (column, _) = ui.allocate_exact_size(vec2(130.0, 0.0), egui::Sense::hover()); ui.separator(); let mut text = story.text.clone(); - let r = ui.add( - egui::TextEdit::multiline(&mut text) - .font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0))) - .desired_width(f32::INFINITY) - .desired_rows(20) - .frame(egui::Frame::NONE), - ); + let out = egui::TextEdit::multiline(&mut text) + .font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0))) + .desired_width(f32::INFINITY) + .desired_rows(20) + .frame(egui::Frame::NONE) + .show(ui); + let r = out.response; + let starts = paragraph_start_offsets(out.galley.rows.iter().map(|row| (row.pos.y, row.ends_with_newline))); + for (i, y) in starts.into_iter().enumerate() { + let Some(para) = story.paras.get(i) else { break }; + let rect = egui::Rect::from_min_size(egui::pos2(column.min.x, out.galley_pos.y + y), vec2(130.0, 14.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let name = crate::i18n::style_name(&app.ui.language, ¶.style); + crate::rtl::label(ui, egui::RichText::new(name).size(11.0).color(t.text_dim)); + }); + } if r.changed() { // Minimal diff: common prefix and suffix. let old = &story.text; @@ -111,6 +110,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } } +/// Vertical offset of the first laid-out row of each paragraph, from `(row y, row ends with a newline)` +/// in layout order. A paragraph that wraps spans several rows, so counting characters per line +/// drifts from what the editor draws; the rows themselves are the truth. +fn paragraph_start_offsets(rows: impl Iterator) -> Vec { + let mut starts = Vec::new(); + let mut at_start = true; + for (y, ends_with_newline) in rows { + if at_start { + starts.push(y); + } + at_start = ends_with_newline; + } + starts +} + #[cfg(test)] mod tests { use egui_kittest::kittest::Queryable; @@ -123,6 +137,13 @@ mod tests { st.doc.stories.values().next().unwrap().text.clone() } + /// A wrapped paragraph takes several rows; the next paragraph's label goes to the row after them. + #[test] + fn labels_follow_wrapped_rows() { + let rows = [(0.0, false), (20.0, false), (40.0, true), (60.0, true), (80.0, false)]; + assert_eq!(super::paragraph_start_offsets(rows.into_iter()), vec![0.0, 60.0, 80.0]); + } + /// Story ids are counted per document: typing in the editor after switching documents edited /// the other document's story with the same id. #[test] From 287b65f788de73996610609ec9a8391edaf6fd7c Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:15:45 -0600 Subject: [PATCH 33/41] Object: reject malformed stroke types --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 85 +++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..98616051f 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** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. 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/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 2d5817600..bf3716d37 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -259,9 +259,28 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}", + "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed|stripes|style, pattern?: [dash, gap, …] (dashed: required, points), bands?: [[start, width], …] (stripes: required, fractions of stroke weight), name?: string (style: required, document stroke style name)} (other kinds take no extra fields; unknown type fields are errors), cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}", has_doc, |s, p| { + let kind: Option = p + .get("type") + .map(|v| { + if let Some(fields) = v.as_object() { + let field = match v.get("kind").and_then(Value::as_str) { + Some("dashed") => Some("pattern"), + Some("stripes") => Some("bands"), + Some("style") => Some("name"), + _ => None, + }; + for key in fields.keys() { + if key != "kind" && Some(key.as_str()) != field { + return Err(bad("object.stroke", format!("type: unknown field `{key}`"))); + } + } + } + serde_json::from_value(v.clone()).map_err(|e| bad("object.stroke", format!("type: {e}"))) + }) + .transpose()?; for key in ["swatch", "gapSwatch"] { if let Some(name) = p.get(key).and_then(Value::as_str) { known_swatch(&s.doc()?.doc, name, "object.stroke", key)?; @@ -314,11 +333,13 @@ pub fn specs() -> Vec { if let Some(v) = p2.get("gapOverprint").and_then(Value::as_bool) { st.gap_overprint = v; } - for (k, apply) in [("align", 0), ("type", 1), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] { + if let Some(kind) = &kind { + st.kind = kind.clone(); + } + for (k, apply) in [("align", 0), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] { if let Some(v) = p2.get(k).cloned() { let _ = match apply { 0 => serde_json::from_value(v).map(|x| st.align = x), - 1 => serde_json::from_value(v).map(|x| st.kind = x), 2 => serde_json::from_value(v).map(|x| st.cap = x), 3 => serde_json::from_value(v).map(|x| st.join = x), 4 => serde_json::from_value(v).map(|x| st.start = x), @@ -4799,6 +4820,64 @@ mod group_tests { } } +#[cfg(test)] +mod stroke_type_tests { + use serde_json::json; + + #[test] + fn malformed_stroke_types_return_bad_params_without_editing() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone(); + for (kind, hint) in [ + (json!({"kind": "dashed", "dash": [8, 2.5]}), "dash"), + (json!({"kind": "zigzag"}), "zigzag"), + (json!({"kind": "solid", "dash": [8, 2.5]}), "dash"), + (json!({"kind": "dashed", "pattern": [8, 2.5], "dash": [1, 2]}), "dash"), + (json!({"kind": "dashed"}), "pattern"), + (json!({"kind": "dashed", "pattern": "bad"}), "type"), + (json!({"kind": "stripes", "bands": [[0, "bad"]]}), "type"), + (json!({"kind": "style"}), "name"), + (json!({}), "kind"), + (json!("solid"), "type"), + (json!(null), "type"), + ] { + let before = s.doc().unwrap().doc.clone(); + let error = s.execute("object.stroke", &json!({"ids": [id], "weight": 5, "type": kind})).unwrap_err(); + assert!(matches!(error, crate::EngineError::BadParams { .. }), "{error}"); + assert!(error.to_string().contains(hint), "{kind}: {error}"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{kind}: nothing changed"); + } + } + + #[test] + fn stroke_types_apply_all_supported_variants() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + for kind in [ + json!({"kind": "solid"}), + json!({"kind": "dashed", "pattern": [8, 2.5]}), + json!({"kind": "dotted"}), + json!({"kind": "thickThin"}), + json!({"kind": "thinThick"}), + json!({"kind": "thinThin"}), + json!({"kind": "thickThick"}), + json!({"kind": "thinThickThin"}), + json!({"kind": "thickThinThick"}), + json!({"kind": "wavy"}), + json!({"kind": "hashed"}), + json!({"kind": "stripes", "bands": [[0, 0.3], [0.7, 0.3]]}), + json!({"kind": "style", "name": "Rails"}), + ] { + let result = s.execute("object.stroke", &json!({"ids": [id], "type": kind})).unwrap(); + assert_eq!(result["changed"], 1); + let item = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(item.stroke.kind, serde_json::from_value::(kind).unwrap()); + } + } +} + #[cfg(test)] mod swatch_name_tests { use serde_json::json; From 3b3544477d19f385b3c78a100e233f41cc3ff3a5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:04:40 +0000 Subject: [PATCH 34/41] Show page and resolution of anchored images in the Links panel Fixes #1269 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/links.rs | 99 ++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index d77db3ea8..c6e9e7566 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -14,7 +14,9 @@ use std::path::Path; use std::sync::Arc; -use designcraft_doc::{AssetId, Content, Document, Fingerprint, ItemId, Selection, Story, StoryId, TextFileLink}; +use designcraft_compose::Cache; +use designcraft_doc::{AssetId, Content, Document, Fingerprint, Item, ItemId, Selection, Story, StoryId, TextFileLink}; +use designcraft_geom::Affine; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; @@ -25,7 +27,7 @@ pub fn specs() -> Vec { cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { // Taken out for the call so the document needn't be cloned on every poll. let mut checks = std::mem::take(&mut s.text_link_checks); - let links = s.doc().map(|d| list(&d.doc, d.path.as_deref().map(Path::new), &mut checks)); + let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks)); s.text_link_checks = checks; Ok(Value::Array(links?)) }), @@ -262,7 +264,60 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) } -fn list(d: &Document, document: Option<&Path>, checks: &mut TextChecks) -> Vec { +/// A graphic showing an asset: the item, its page, and the transform from its parent's space to +/// the spread. +struct Shown<'a> { + item: &'a Item, + page: Option, + parent_xf: Affine, +} + +/// Where an asset shows: frames on spreads (see [`uses`]) and anchored or inline objects, whose +/// page and position come from the frame their story lays them out in. +fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec> { + fn walk<'a>(it: &'a Item, xf: Affine, aid: AssetId, page: Option, out: &mut Vec>) { + if let Content::Graphic(g) = &it.content + && g.asset == aid + { + out.push(Shown { item: it, page, parent_xf: xf }); + } + for c in it.children() { + walk(c, xf * it.xf, aid, page, out); + } + } + let mut out: Vec = uses(d, aid) + .into_iter() + .filter_map(|id| { + let loc = d.find(id)?; + Some(Shown { item: d.item_at(&loc)?, page: d.page_of_item(id), parent_xf: d.parent_xf(&loc) }) + }) + .collect(); + for st in d.stories.values() { + let mut composed = None; + for (k, o) in st.objects.iter().enumerate() { + let mut found = false; + o.item.walk(&mut |i| found |= matches!(&i.content, Content::Graphic(g) if g.asset == aid)); + if !found { + continue; + } + let cs = composed.get_or_insert_with(|| cache.get(d, st.id, None)); + let placed = cs.frames.iter().find_map(|ft| ft.objects.iter().find(|p| p.index == k).map(|p| (ft.frame, p.origin))); + // Overset: no page; the resolution of the object as it would show. + let (page, xf) = match placed { + Some((fid, origin)) => { + let frame_xf = + d.find(fid).map_or(Affine::IDENTITY, |loc| d.parent_xf(&loc)) * d.item(fid).map_or(Affine::IDENTITY, |i| d.text_xf(i)); + (d.page_of_item(fid), frame_xf * Affine::translate(origin.to_vec2())) + } + None => (None, Affine::IDENTITY), + }; + walk(&o.item, xf, aid, page, &mut out); + } + } + out +} + +fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks) -> Vec { let texts = d.stories.values().filter_map(|st| { let l = st.file_link.as_ref()?; let uses: Vec = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect(); @@ -273,25 +328,24 @@ fn list(d: &Document, document: Option<&Path>, checks: &mut TextChecks) -> Vec = uses(d, a.id) + let uses: Vec = shown(d, cache, a.id) .into_iter() - .map(|id| { + .map(|u| { + let it = u.item; // Effective resolution: image pixels over the size it is shown at. let ppi = (|| { - let loc = d.find(id)?; - let it = d.item_at(&loc)?; let Content::Graphic(g) = &it.content else { return None }; // Vector (placed PDF or SVG): no effective resolution. if a.mime == "application/pdf" || a.mime == "image/svg+xml" { return None; } let (pw, _) = a.pixels?; - let xf = d.parent_xf(&loc) * it.xf * g.xf; + let xf = u.parent_xf * it.xf * g.xf; let shown_w = xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)).width(); (shown_w > 0.0).then(|| (pw as f64 / (shown_w / 72.0)).round()) })(); - let page = d.page_of_item(id).map(|p| d.page_name(p)); - json!({"id": id.0, "page": page, "ppi": ppi}) + let page = u.page.map(|p| d.page_name(p)); + json!({"id": it.id.0, "page": page, "ppi": ppi}) }) .collect(); let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name()); @@ -527,6 +581,31 @@ mod tests { super::super::file::base64_decode(result["base64"].as_str().unwrap()) } + #[test] + fn anchored_image_lists_its_page_and_resolution() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let r = s + .execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let placed = s + .execute( + "file.place", + &json!({"base64": super::super::file::base64_encode(&png(10, 10)), "name": "dot.png", "x": 450, "y": 450, "width": 36}), + ) + .unwrap(); + let id = placed["id"].as_u64().unwrap(); + let before = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(before[0]["uses"][0]["ppi"], 20.0); + s.execute("anchored.insert", &json!({"ids": [id], "story": sid.0, "pos": 7})).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + let uses = list[0]["uses"].as_array().unwrap(); + assert_eq!(uses.len(), 1, "anchored image must have one use: {list}"); + assert_eq!(uses[0]["page"], "2"); + assert_eq!(uses[0]["ppi"], 20.0); + } + #[test] fn eps_web_exports_convert_tiff_and_skip_bad_used_assets() { use std::io::Read; From 07b4956e0f05e39630d4ebe45bf49b94db2351a5 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:30:00 -0600 Subject: [PATCH 35/41] Docs: install the CLI and register it with MCP clients --- ROADMAP.md | 2 ++ docs/mcp.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..292a5c55b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. 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/docs/mcp.md b/docs/mcp.md index bba394e33..1aba6b224 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7,21 +7,64 @@ the result as PNG images. It speaks newline-delimited JSON-RPC 2.0 on stdin/stdo ## Register it -Build the CLI (`cargo build --release -p designcraft-cli`), then: +### From source (development) + +With Rust and Cargo installed, run this from the repository root: + +```sh +cargo install --path apps/designcraft-cli --locked +``` + +Cargo copies the executable into its installation root's `bin` directory, normally +`~/.cargo/bin` (Windows `%USERPROFILE%\.cargo\bin`). If you set `CARGO_INSTALL_ROOT`, +Cargo's `install.root` configuration, or `CARGO_HOME`, use that installation's `bin` +directory instead. Unlike a symlink into `target/release`, this copy survives +`cargo clean`. Repeat the install command after source changes to update it. + +Ensure that directory is on `PATH`. For a default install in a POSIX shell: + +```sh +export PATH="$HOME/.cargo/bin:$PATH" +designcraft-cli --help +``` + +Persist the PATH setting in your shell configuration (on Windows, add the directory +to your user Path). The **MCP client process** must inherit that PATH too; restart +the client after changing it. A GUI-launched client may not read shell configuration. +If it still reports `ENOENT` or "executable not found", use the absolute path to the +installed binary in its registration instead of the bare name. + +### Headless and connected registration + +After installation, register the CLI by name: ```sh # Headless: an in-process engine, no window -claude mcp add designcraft -- /path/to/target/release/designcraft-cli mcp +claude mcp add designcraft -- designcraft-cli mcp -# Drive the running desktop app (start it first with a control port) +# Drive the running desktop app (install/start the desktop app separately) designcraft --control 7979 -claude mcp add designcraft-app -- /path/to/target/release/designcraft-cli mcp --connect 7979 +claude mcp add designcraft-app -- designcraft-cli mcp --connect 7979 ``` Other MCP clients use the same command line, e.g. in a JSON config: ```json -{"mcpServers": {"designcraft": {"command": "/path/to/designcraft-cli", "args": ["mcp"]}}} +{ + "mcpServers": { + "designcraft": { + "command": "designcraft-cli", + "args": ["mcp"] + } + } +} +``` + +If the client cannot find the CLI on PATH, substitute the absolute installed path +(not a path under `target/`), for example: + +```sh +claude mcp add designcraft -- /absolute/install/root/bin/designcraft-cli mcp ``` ### From an installed release From 49fc2c120924b88e3d087adece26c57dbf32a12a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:43:14 +0200 Subject: [PATCH 36/41] Clamp RGB, CMYK and Gray components on ASE import Found while testing crates/color/src/ase.rs:100-103 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/ase.rs | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/color/src/ase.rs b/crates/color/src/ase.rs index c20c249ec..6cfd72034 100644 --- a/crates/color/src/ase.rs +++ b/crates/color/src/ase.rs @@ -98,9 +98,11 @@ pub fn read(bytes: &[u8]) -> Result, AseError> { } } let color = match &model[..] { - b"CMYK" => Color::Cmyk { c: b.f32()?, m: b.f32()?, y: b.f32()?, k: b.f32()? }, - b"RGB " => Color::Rgb { r: b.f32()?, g: b.f32()?, b: b.f32()? }, - b"Gray" => Color::Gray { k: 1.0 - b.f32()? }, + b"CMYK" => { + Color::Cmyk { c: b.f32()?.clamp(0.0, 1.0), m: b.f32()?.clamp(0.0, 1.0), y: b.f32()?.clamp(0.0, 1.0), k: b.f32()?.clamp(0.0, 1.0) } + } + b"RGB " => Color::Rgb { r: b.f32()?.clamp(0.0, 1.0), g: b.f32()?.clamp(0.0, 1.0), b: b.f32()?.clamp(0.0, 1.0) }, + b"Gray" => Color::Gray { k: (1.0 - b.f32()?).clamp(0.0, 1.0) }, b"LAB " => { let (l, a, bb) = (b.f32()?, b.f32()?, b.f32()?); let [r, g, bl] = cms::lab::lab_to_srgb(cms::Lab { l: l * 100.0, a, b: bb }); @@ -231,6 +233,36 @@ mod tests { assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit"); } + #[test] + fn out_of_range_components_are_clamped() { + let mut f = Vec::new(); + f.extend(b"ASEF\x00\x01\x00\x00"); + f.extend(3u32.to_be_bytes()); + let cases: [(&[u8; 4], &[f32]); 3] = [(b"RGB ", &[5.0, -2.0, 0.5]), (b"CMYK", &[2.0, -1.0, 0.5, 9.0]), (b"Gray", &[-1.0])]; + for (model, vals) in cases { + let mut c = Vec::new(); + c.extend(2u16.to_be_bytes()); + c.extend([0, b'X', 0, 0]); + c.extend(model); + for v in vals { + c.extend(v.to_bits().to_be_bytes()); + } + c.extend(2u16.to_be_bytes()); + f.extend(COLOR.to_be_bytes()); + f.extend((c.len() as u32).to_be_bytes()); + f.extend(&c); + } + let colors: Vec = read(&f) + .unwrap() + .into_iter() + .filter_map(|s| match s.value { + SwatchValue::Color { color, .. } => Some(color), + _ => None, + }) + .collect(); + assert_eq!(colors, [Color::Rgb { r: 1.0, g: 0.0, b: 0.5 }, Color::Cmyk { c: 1.0, m: 0.0, y: 0.5, k: 1.0 }, Color::Gray { k: 1.0 },]); + } + #[test] fn non_finite_components_are_skipped() { let sw = |name: &str, color: Color| Swatch { From 0d0fa585a0e49d333dc1d8f8fc92b0a0fbb6af20 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:23:07 -0400 Subject: [PATCH 37/41] story.replaceRange: convert offsets with usize::try_from (wasm32-safe) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 32cbfd0f6..12bd540b7 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -125,8 +125,12 @@ pub fn specs() -> Vec { has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?); - let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize; - let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a); + let offset = |v: u64| usize::try_from(v).map_err(|_| bad("story.replaceRange", format!("offset {v} is out of range"))); + let a = offset(p.get("start").and_then(Value::as_u64).unwrap_or(0))?; + let b = match p.get("end").and_then(Value::as_u64) { + Some(v) => offset(v)?, + None => a, + }; let text = super::text_param(p, "text"); s.edit(|d, sel| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; From 8981d5e4cdfd58a471db32fd2f817543e5b59a70 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:31:31 -0400 Subject: [PATCH 38/41] IDML import: ignore non-finite Minimum First Baseline offsets, clamp to 0..8640 Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 3493d60a7..7490218cb 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1190,7 +1190,7 @@ impl<'r> Importer<'r> { } cs.vj = e.get("VerticalJustification").map(names::vj_in); cs.first_baseline = e.get("FirstBaselineOffset").map(names::first_baseline_in); - cs.first_baseline_min = e.num("MinimumFirstBaselineOffset").map(|v| v.max(0.0)); + cs.first_baseline_min = e.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 8640.0)); cs.strokes = ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|side| self.cell_stroke_attrs(e, side)); if let Some(stroke) = uniform_cell_stroke(&cs.strokes) { cs.stroke = Some(stroke); @@ -2218,8 +2218,8 @@ impl<'r> Importer<'r> { if let Some(fb) = ce.get("FirstBaselineOffset") { cell.first_baseline = names::first_baseline_in(fb); } - if let Some(min) = ce.num("MinimumFirstBaselineOffset") { - cell.first_baseline_min = min.max(0.0); + if let Some(min) = ce.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()) { + cell.first_baseline_min = min.clamp(0.0, 8640.0); } cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { From dcb0417b63f58fbbc845adde61c27db0cfcd8720 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:38:32 -0400 Subject: [PATCH 39/41] =?UTF-8?q?Dock:=20the=20=C2=BB=20chevron=20runs=20w?= =?UTF-8?q?indow.collapseToIcons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 544f00721..d4551e813 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -69,7 +69,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui, |ui| { if dock_header(ui, &t, "»").clicked() { - app.ui.dock_expanded = false; + let _ = app.run("window.collapseToIcons", serde_json::json!({})); } // Tab strip (27 pt). let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover()); From b741d0dabb6dcfd5675ce1a8d35bd07470231cdc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:38:43 -0400 Subject: [PATCH 40/41] ROADMAP: move the package-links note into the progress log Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3729509c..6eb84f3d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,6 @@ # DesignCraft roadmap **Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -- PK340: 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. - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 @@ -349,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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. - **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. From 4e848d9727da6cc7ba55d106d94aa1ae38faa14c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:38:44 -0400 Subject: [PATCH 41/41] ROADMAP: drop the duplicate review line and the stray blank line Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 866d8fc30..535c6b21e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -215,7 +215,6 @@ same references), and from the app's JSON control channel. See [docs/agents.md]( | # | Milestone | State | **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (undoable parent deletion) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (A0, A1 and A2 poster presets) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026 @@ -350,7 +349,6 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **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-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.